Skip to content

Commit eb653fe

Browse files
committed
feat(search): implement advanced search with filters, pagination, and full-text search capabilities
1 parent 900be4a commit eb653fe

13 files changed

Lines changed: 808 additions & 1 deletion

backend/prisma/schema.prisma

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ model Student {
2727
learningProgress LearningProgress[]
2828
2929
@@map("students")
30+
@@index([firstName, lastName])
31+
@@index([email])
32+
@@index([createdAt])
3033
}
3134

3235
model Course {
@@ -44,6 +47,10 @@ model Course {
4447
learningProgress LearningProgress[]
4548
4649
@@map("courses")
50+
@@index([title])
51+
@@index([instructor])
52+
@@index([credits])
53+
@@index([createdAt])
4754
}
4855

4956
model Certificate {
@@ -59,6 +66,10 @@ model Certificate {
5966
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
6067
6168
@@map("certificates")
69+
@@index([status])
70+
@@index([issuedAt])
71+
@@index([studentId])
72+
@@index([courseId])
6273
}
6374

6475
model Enrollment {

backend/src/routes/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Router } from 'express';
2+
import blockchainRouter from '../blockchain/balance.js';
23
import dashboardRouter from '../dashboard/dashboard.routes.js';
34
import feedbackRouter from '../feedback/feedback.routes.js';
45
import userRouter from '../user/routes.js';
@@ -9,7 +10,6 @@ import enrollmentsRouter from './enrollments.js';
910
import generatorRoutes from './generator/generator.routes.js';
1011
import learningRoutes from './learning/learning.routes.js';
1112
import studentsRouter from './students.js';
12-
import blockchainRouter from '../blockchain/balance.js';
1313

1414
const router = Router();
1515

@@ -23,6 +23,7 @@ router.use('/dashboard', dashboardRouter);
2323
router.use('/auth', authRoutes);
2424
router.use('/learning', learningRoutes);
2525
router.use('/generator', generatorRoutes);
26+
router.use('/search', searchRoutes);
2627
router.use('/user', userRouter);
2728

2829
// Blockchain routes
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { PrismaClient } from '@prisma/client';
2+
import { Request, Response } from 'express';
3+
import { CertificateSearchService } from '../../search/CertificateSearchService.js';
4+
import { CourseSearchService } from '../../search/CourseSearchService.js';
5+
import { FilterParser } from '../../search/FilterParser.js';
6+
import { SearchOptions } from '../../search/SearchService.js';
7+
import { StudentSearchService } from '../../search/StudentSearchService.js';
8+
import logger from '../../utils/logger.js';
9+
10+
export class SearchController {
11+
private prisma: PrismaClient;
12+
private courseSearchService: CourseSearchService;
13+
private studentSearchService: StudentSearchService;
14+
private certificateSearchService: CertificateSearchService;
15+
private filterParser: FilterParser;
16+
17+
constructor(prisma: PrismaClient) {
18+
this.prisma = prisma;
19+
this.courseSearchService = new CourseSearchService(prisma);
20+
this.studentSearchService = new StudentSearchService(prisma);
21+
this.certificateSearchService = new CertificateSearchService(prisma);
22+
this.filterParser = new FilterParser();
23+
}
24+
25+
private parseSearchOptions(req: Request): SearchOptions {
26+
const { query, page, limit, cursor, sort } = req.query;
27+
28+
// Parse filters from query string
29+
const filters = this.filterParser.parseQueryStringFilters(req.url.split('?')[1] || '');
30+
31+
return {
32+
query: query as string,
33+
filters,
34+
sort: sort as string,
35+
page: page ? parseInt(page as string) : undefined,
36+
limit: limit ? parseInt(limit as string) : undefined,
37+
cursor: cursor as string,
38+
};
39+
}
40+
41+
async searchCourses(req: Request, res: Response) {
42+
try {
43+
const options = this.parseSearchOptions(req);
44+
const result = await this.courseSearchService.search(options);
45+
46+
res.json(result);
47+
} catch (error) {
48+
logger.error('Course search error:', error);
49+
res.status(400).json({
50+
error: 'Invalid search parameters',
51+
message: error instanceof Error ? error.message : 'Unknown error',
52+
});
53+
}
54+
}
55+
56+
async searchStudents(req: Request, res: Response) {
57+
try {
58+
const options = this.parseSearchOptions(req);
59+
const result = await this.studentSearchService.search(options);
60+
61+
res.json(result);
62+
} catch (error) {
63+
logger.error('Student search error:', error);
64+
res.status(400).json({
65+
error: 'Invalid search parameters',
66+
message: error instanceof Error ? error.message : 'Unknown error',
67+
});
68+
}
69+
}
70+
71+
async searchCertificates(req: Request, res: Response) {
72+
try {
73+
const options = this.parseSearchOptions(req);
74+
const result = await this.certificateSearchService.search(options);
75+
76+
res.json(result);
77+
} catch (error) {
78+
logger.error('Certificate search error:', error);
79+
res.status(400).json({
80+
error: 'Invalid search parameters',
81+
message: error instanceof Error ? error.message : 'Unknown error',
82+
});
83+
}
84+
}
85+
86+
async searchAll(req: Request, res: Response) {
87+
try {
88+
const options = this.parseSearchOptions(req);
89+
90+
// Execute all searches in parallel
91+
const [courses, students, certificates] = await Promise.all([
92+
this.courseSearchService.search({ ...options, limit: 5 }), // Limit results for global search
93+
this.studentSearchService.search({ ...options, limit: 5 }),
94+
this.certificateSearchService.search({ ...options, limit: 5 }),
95+
]);
96+
97+
res.json({
98+
courses: courses.data,
99+
students: students.data,
100+
certificates: certificates.data,
101+
metadata: {
102+
query: options.query,
103+
filters: options.filters,
104+
totalResults: courses.metadata.totalResults + students.metadata.totalResults + certificates.metadata.totalResults,
105+
},
106+
});
107+
} catch (error) {
108+
logger.error('Global search error:', error);
109+
res.status(400).json({
110+
error: 'Invalid search parameters',
111+
message: error instanceof Error ? error.message : 'Unknown error',
112+
});
113+
}
114+
}
115+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { Router } from 'express';
2+
import prisma from '../../db/index.js';
3+
import { SearchController } from './search.controller.js';
4+
5+
const router = Router();
6+
const searchController = new SearchController(prisma);
7+
8+
// Search endpoints
9+
router.get('/courses', (req, res) => searchController.searchCourses(req, res));
10+
router.get('/students', (req, res) => searchController.searchStudents(req, res));
11+
router.get('/certificates', (req, res) => searchController.searchCertificates(req, res));
12+
router.get('/all', (req, res) => searchController.searchAll(req, res));
13+
14+
export default router;
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { z } from 'zod';
2+
3+
export const searchQuerySchema = z.object({
4+
query: z.string().optional(),
5+
page: z.string().transform(val => parseInt(val)).refine(val => val > 0).optional(),
6+
limit: z.string().transform(val => parseInt(val)).refine(val => val > 0 && val <= 100).optional(),
7+
cursor: z.string().optional(),
8+
sort: z.string().optional(),
9+
});
10+
11+
export const searchFiltersSchema = z.record(z.any());
12+
13+
export const searchParamsSchema = z.object({
14+
query: z.string().optional(),
15+
filters: z.record(z.any()).optional(),
16+
sort: z.string().optional(),
17+
page: z.number().int().min(1).optional(),
18+
limit: z.number().int().min(1).max(100).optional(),
19+
cursor: z.string().optional(),
20+
}).refine(
21+
(data) => !(data.page && data.cursor),
22+
{
23+
message: "Cannot use both 'page' and 'cursor' pagination",
24+
path: ["page", "cursor"],
25+
}
26+
);
27+
28+
// Type exports
29+
export type SearchQueryParams = z.infer<typeof searchQuerySchema>;
30+
export type SearchFilters = z.infer<typeof searchFiltersSchema>;
31+
export type SearchParams = z.infer<typeof searchParamsSchema>;
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { PrismaClient } from '@prisma/client';
2+
import { SearchOptions, SearchResult, SearchService } from './SearchService.js';
3+
4+
export class CertificateSearchService extends SearchService<any> {
5+
private readonly searchFields = ['certificateHash', 'status'];
6+
private readonly filterFields = {
7+
status: 'string',
8+
issuedAt: 'date',
9+
did: 'string',
10+
};
11+
private readonly sortFields = ['issuedAt', 'status'];
12+
13+
constructor(prisma: PrismaClient) {
14+
super(prisma);
15+
}
16+
17+
async search(options: SearchOptions): Promise<SearchResult<any>> {
18+
// Validate sort field
19+
if (options.sort) {
20+
this.validator.validateSortField(options.sort, this.sortFields);
21+
}
22+
23+
// Validate filter fields
24+
if (options.filters) {
25+
this.validator.validateFilterFields(options.filters, Object.keys(this.filterFields));
26+
}
27+
28+
// For certificates, we need to include related student and course data
29+
const result = await this.executeSearch(
30+
this.prisma.certificate,
31+
options,
32+
this.searchFields,
33+
this.filterFields
34+
);
35+
36+
// Enrich with student and course data
37+
const enrichedData = await Promise.all(
38+
result.data.map(async (cert: any) => {
39+
const student = await this.prisma.student.findUnique({
40+
where: { id: cert.studentId },
41+
select: { firstName: true, lastName: true, email: true },
42+
});
43+
44+
const course = await this.prisma.course.findUnique({
45+
where: { id: cert.courseId },
46+
select: { title: true, instructor: true },
47+
});
48+
49+
return {
50+
...cert,
51+
student: student ? `${student.firstName} ${student.lastName}` : 'Unknown',
52+
studentEmail: student?.email,
53+
course: course?.title || 'Unknown',
54+
instructor: course?.instructor,
55+
};
56+
})
57+
);
58+
59+
return {
60+
...result,
61+
data: enrichedData,
62+
};
63+
}
64+
65+
getEntityName(): string {
66+
return 'certificates';
67+
}
68+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { PrismaClient } from '@prisma/client';
2+
import { SearchOptions, SearchResult, SearchService } from './SearchService.js';
3+
4+
export class CourseSearchService extends SearchService<any> {
5+
private readonly searchFields = ['title', 'description', 'instructor'];
6+
private readonly filterFields = {
7+
credits: 'number',
8+
instructor: 'string',
9+
createdAt: 'date',
10+
};
11+
private readonly sortFields = ['title', 'credits', 'createdAt', 'instructor'];
12+
13+
constructor(prisma: PrismaClient) {
14+
super(prisma);
15+
}
16+
17+
async search(options: SearchOptions): Promise<SearchResult<any>> {
18+
// Validate sort field
19+
if (options.sort) {
20+
this.validator.validateSortField(options.sort, this.sortFields);
21+
}
22+
23+
// Validate filter fields
24+
if (options.filters) {
25+
this.validator.validateFilterFields(options.filters, Object.keys(this.filterFields));
26+
}
27+
28+
return this.executeSearch(
29+
this.prisma.course,
30+
options,
31+
this.searchFields,
32+
this.filterFields
33+
);
34+
}
35+
36+
getEntityName(): string {
37+
return 'courses';
38+
}
39+
}

0 commit comments

Comments
 (0)