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
11 changes: 11 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ model Student {
learningProgress LearningProgress[]

@@map("students")
@@index([firstName, lastName])
@@index([email])
@@index([createdAt])
}

model Course {
Expand All @@ -44,6 +47,10 @@ model Course {
learningProgress LearningProgress[]

@@map("courses")
@@index([title])
@@index([instructor])
@@index([credits])
@@index([createdAt])
}

model Certificate {
Expand All @@ -59,6 +66,10 @@ model Certificate {
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)

@@map("certificates")
@@index([status])
@@index([issuedAt])
@@index([studentId])
@@index([courseId])
}

model Enrollment {
Expand Down
3 changes: 2 additions & 1 deletion backend/src/routes/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Router } from 'express';
import blockchainRouter from '../blockchain/balance.js';
import dashboardRouter from '../dashboard/dashboard.routes.js';
import feedbackRouter from '../feedback/feedback.routes.js';
import userRouter from '../user/routes.js';
Expand All @@ -9,7 +10,6 @@ import enrollmentsRouter from './enrollments.js';
import generatorRoutes from './generator/generator.routes.js';
import learningRoutes from './learning/learning.routes.js';
import studentsRouter from './students.js';
import blockchainRouter from '../blockchain/balance.js';

const router = Router();

Expand All @@ -23,6 +23,7 @@ router.use('/dashboard', dashboardRouter);
router.use('/auth', authRoutes);
router.use('/learning', learningRoutes);
router.use('/generator', generatorRoutes);
router.use('/search', searchRoutes);
router.use('/user', userRouter);

// Blockchain routes
Expand Down
115 changes: 115 additions & 0 deletions backend/src/routes/search/search.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { PrismaClient } from '@prisma/client';
import { Request, Response } from 'express';
import { CertificateSearchService } from '../../search/CertificateSearchService.js';
import { CourseSearchService } from '../../search/CourseSearchService.js';
import { FilterParser } from '../../search/FilterParser.js';
import { SearchOptions } from '../../search/SearchService.js';
import { StudentSearchService } from '../../search/StudentSearchService.js';
import logger from '../../utils/logger.js';

export class SearchController {
private prisma: PrismaClient;
private courseSearchService: CourseSearchService;
private studentSearchService: StudentSearchService;
private certificateSearchService: CertificateSearchService;
private filterParser: FilterParser;

constructor(prisma: PrismaClient) {
this.prisma = prisma;
this.courseSearchService = new CourseSearchService(prisma);
this.studentSearchService = new StudentSearchService(prisma);
this.certificateSearchService = new CertificateSearchService(prisma);
this.filterParser = new FilterParser();
}

private parseSearchOptions(req: Request): SearchOptions {
const { query, page, limit, cursor, sort } = req.query;

// Parse filters from query string
const filters = this.filterParser.parseQueryStringFilters(req.url.split('?')[1] || '');

return {
query: query as string,
filters,
sort: sort as string,
page: page ? parseInt(page as string) : undefined,
limit: limit ? parseInt(limit as string) : undefined,
cursor: cursor as string,
};
}

async searchCourses(req: Request, res: Response) {
try {
const options = this.parseSearchOptions(req);
const result = await this.courseSearchService.search(options);

res.json(result);
} catch (error) {
logger.error('Course search error:', error);
res.status(400).json({
error: 'Invalid search parameters',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
}

async searchStudents(req: Request, res: Response) {
try {
const options = this.parseSearchOptions(req);
const result = await this.studentSearchService.search(options);

res.json(result);
} catch (error) {
logger.error('Student search error:', error);
res.status(400).json({
error: 'Invalid search parameters',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
}

async searchCertificates(req: Request, res: Response) {
try {
const options = this.parseSearchOptions(req);
const result = await this.certificateSearchService.search(options);

res.json(result);
} catch (error) {
logger.error('Certificate search error:', error);
res.status(400).json({
error: 'Invalid search parameters',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
}

async searchAll(req: Request, res: Response) {
try {
const options = this.parseSearchOptions(req);

// Execute all searches in parallel
const [courses, students, certificates] = await Promise.all([
this.courseSearchService.search({ ...options, limit: 5 }), // Limit results for global search
this.studentSearchService.search({ ...options, limit: 5 }),
this.certificateSearchService.search({ ...options, limit: 5 }),
]);

res.json({
courses: courses.data,
students: students.data,
certificates: certificates.data,
metadata: {
query: options.query,
filters: options.filters,
totalResults: courses.metadata.totalResults + students.metadata.totalResults + certificates.metadata.totalResults,
},
});
} catch (error) {
logger.error('Global search error:', error);
res.status(400).json({
error: 'Invalid search parameters',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
}
}
14 changes: 14 additions & 0 deletions backend/src/routes/search/search.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Router } from 'express';
import prisma from '../../db/index.js';
import { SearchController } from './search.controller.js';

const router = Router();
const searchController = new SearchController(prisma);

// Search endpoints
router.get('/courses', (req, res) => searchController.searchCourses(req, res));
router.get('/students', (req, res) => searchController.searchStudents(req, res));
router.get('/certificates', (req, res) => searchController.searchCertificates(req, res));
router.get('/all', (req, res) => searchController.searchAll(req, res));

export default router;
31 changes: 31 additions & 0 deletions backend/src/routes/search/search.schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { z } from 'zod';

export const searchQuerySchema = z.object({
query: z.string().optional(),
page: z.string().transform(val => parseInt(val)).refine(val => val > 0).optional(),
limit: z.string().transform(val => parseInt(val)).refine(val => val > 0 && val <= 100).optional(),
cursor: z.string().optional(),
sort: z.string().optional(),
});

export const searchFiltersSchema = z.record(z.any());

export const searchParamsSchema = z.object({
query: z.string().optional(),
filters: z.record(z.any()).optional(),
sort: z.string().optional(),
page: z.number().int().min(1).optional(),
limit: z.number().int().min(1).max(100).optional(),
cursor: z.string().optional(),
}).refine(
(data) => !(data.page && data.cursor),
{
message: "Cannot use both 'page' and 'cursor' pagination",
path: ["page", "cursor"],
}
);

// Type exports
export type SearchQueryParams = z.infer<typeof searchQuerySchema>;
export type SearchFilters = z.infer<typeof searchFiltersSchema>;
export type SearchParams = z.infer<typeof searchParamsSchema>;
68 changes: 68 additions & 0 deletions backend/src/search/CertificateSearchService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { PrismaClient } from '@prisma/client';
import { SearchOptions, SearchResult, SearchService } from './SearchService.js';

export class CertificateSearchService extends SearchService<any> {

Check failure on line 4 in backend/src/search/CertificateSearchService.ts

View workflow job for this annotation

GitHub Actions / Backend (ESLint + Prettier)

Unexpected any. Specify a different type
private readonly searchFields = ['certificateHash', 'status'];
private readonly filterFields = {
status: 'string',
issuedAt: 'date',
did: 'string',
};
private readonly sortFields = ['issuedAt', 'status'];

constructor(prisma: PrismaClient) {
super(prisma);
}

async search(options: SearchOptions): Promise<SearchResult<any>> {

Check failure on line 17 in backend/src/search/CertificateSearchService.ts

View workflow job for this annotation

GitHub Actions / Backend (ESLint + Prettier)

Unexpected any. Specify a different type
// Validate sort field
if (options.sort) {
this.validator.validateSortField(options.sort, this.sortFields);
}

// Validate filter fields
if (options.filters) {
this.validator.validateFilterFields(options.filters, Object.keys(this.filterFields));
}

// For certificates, we need to include related student and course data
const result = await this.executeSearch(
this.prisma.certificate,
options,
this.searchFields,
this.filterFields
);

// Enrich with student and course data
const enrichedData = await Promise.all(
result.data.map(async (cert: any) => {

Check failure on line 38 in backend/src/search/CertificateSearchService.ts

View workflow job for this annotation

GitHub Actions / Backend (ESLint + Prettier)

Unexpected any. Specify a different type
const student = await this.prisma.student.findUnique({
where: { id: cert.studentId },
select: { firstName: true, lastName: true, email: true },
});

const course = await this.prisma.course.findUnique({
where: { id: cert.courseId },
select: { title: true, instructor: true },
});

return {
...cert,
student: student ? `${student.firstName} ${student.lastName}` : 'Unknown',
studentEmail: student?.email,
course: course?.title || 'Unknown',
instructor: course?.instructor,
};
})
);

return {
...result,
data: enrichedData,
};
}

getEntityName(): string {
return 'certificates';
}
}
39 changes: 39 additions & 0 deletions backend/src/search/CourseSearchService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { PrismaClient } from '@prisma/client';
import { SearchOptions, SearchResult, SearchService } from './SearchService.js';

export class CourseSearchService extends SearchService<any> {

Check failure on line 4 in backend/src/search/CourseSearchService.ts

View workflow job for this annotation

GitHub Actions / Backend (ESLint + Prettier)

Unexpected any. Specify a different type
private readonly searchFields = ['title', 'description', 'instructor'];
private readonly filterFields = {
credits: 'number',
instructor: 'string',
createdAt: 'date',
};
private readonly sortFields = ['title', 'credits', 'createdAt', 'instructor'];

constructor(prisma: PrismaClient) {
super(prisma);
}

async search(options: SearchOptions): Promise<SearchResult<any>> {

Check failure on line 17 in backend/src/search/CourseSearchService.ts

View workflow job for this annotation

GitHub Actions / Backend (ESLint + Prettier)

Unexpected any. Specify a different type
// Validate sort field
if (options.sort) {
this.validator.validateSortField(options.sort, this.sortFields);
}

// Validate filter fields
if (options.filters) {
this.validator.validateFilterFields(options.filters, Object.keys(this.filterFields));
}

return this.executeSearch(
this.prisma.course,
options,
this.searchFields,
this.filterFields
);
}

getEntityName(): string {
return 'courses';
}
}
Loading
Loading