diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index ebfff8f6..6054e6e7 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -27,6 +27,9 @@ model Student { learningProgress LearningProgress[] @@map("students") + @@index([firstName, lastName]) + @@index([email]) + @@index([createdAt]) } model Course { @@ -44,6 +47,10 @@ model Course { learningProgress LearningProgress[] @@map("courses") + @@index([title]) + @@index([instructor]) + @@index([credits]) + @@index([createdAt]) } model Certificate { @@ -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 { diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index 742e1ffd..213d63bd 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -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'; @@ -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(); @@ -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 diff --git a/backend/src/routes/search/search.controller.ts b/backend/src/routes/search/search.controller.ts new file mode 100644 index 00000000..2a27ee67 --- /dev/null +++ b/backend/src/routes/search/search.controller.ts @@ -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', + }); + } + } +} \ No newline at end of file diff --git a/backend/src/routes/search/search.routes.ts b/backend/src/routes/search/search.routes.ts new file mode 100644 index 00000000..46d2d66e --- /dev/null +++ b/backend/src/routes/search/search.routes.ts @@ -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; \ No newline at end of file diff --git a/backend/src/routes/search/search.schemas.ts b/backend/src/routes/search/search.schemas.ts new file mode 100644 index 00000000..8f0472f9 --- /dev/null +++ b/backend/src/routes/search/search.schemas.ts @@ -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; +export type SearchFilters = z.infer; +export type SearchParams = z.infer; \ No newline at end of file diff --git a/backend/src/search/CertificateSearchService.ts b/backend/src/search/CertificateSearchService.ts new file mode 100644 index 00000000..588af199 --- /dev/null +++ b/backend/src/search/CertificateSearchService.ts @@ -0,0 +1,68 @@ +import { PrismaClient } from '@prisma/client'; +import { SearchOptions, SearchResult, SearchService } from './SearchService.js'; + +export class CertificateSearchService extends SearchService { + 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> { + // 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) => { + 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'; + } +} \ No newline at end of file diff --git a/backend/src/search/CourseSearchService.ts b/backend/src/search/CourseSearchService.ts new file mode 100644 index 00000000..d74348be --- /dev/null +++ b/backend/src/search/CourseSearchService.ts @@ -0,0 +1,39 @@ +import { PrismaClient } from '@prisma/client'; +import { SearchOptions, SearchResult, SearchService } from './SearchService.js'; + +export class CourseSearchService extends SearchService { + 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> { + // 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'; + } +} \ No newline at end of file diff --git a/backend/src/search/FilterParser.ts b/backend/src/search/FilterParser.ts new file mode 100644 index 00000000..d7462097 --- /dev/null +++ b/backend/src/search/FilterParser.ts @@ -0,0 +1,82 @@ +export interface FilterOperator { + eq?: string | number; + neq?: string | number; + gt?: number; + gte?: number; + lt?: number; + lte?: number; + in?: (string | number)[]; + contains?: string; + startsWith?: string; +} + +export class FilterParser { + parseFilters(queryFilters: Record): Record { + const parsedFilters: Record = {}; + + for (const [key, value] of Object.entries(queryFilters)) { + if (typeof value === 'string' || typeof value === 'number' || Array.isArray(value)) { + parsedFilters[key] = { eq: value }; + } else if (typeof value === 'object' && value !== null) { + parsedFilters[key] = value as FilterOperator; + } + } + + return parsedFilters; + } + + parseQueryStringFilters(queryString: string): Record { + const filters: Record = {}; + + // Parse filter[field][operator]=value format + const filterRegex = /filter\[([^\]]+)\]\[([^\]]+)\]=([^&]+)/g; + let match; + + while ((match = filterRegex.exec(queryString)) !== null) { + const [, field, operator, value] = match; + + if (!filters[field]) { + filters[field] = {}; + } + + // Parse value based on operator + filters[field][operator] = this.parseFilterValue(value, operator); + } + + return filters; + } + + private parseFilterValue(value: string, operator: string): any { + // Parse numbers for comparison operators + if (['gt', 'gte', 'lt', 'lte', 'eq', 'neq'].includes(operator)) { + const numValue = Number(value); + return isNaN(numValue) ? value : numValue; + } + + // Parse arrays for 'in' operator + if (operator === 'in') { + return value.split(',').map(v => { + const num = Number(v.trim()); + return isNaN(num) ? v.trim() : num; + }); + } + + // Parse booleans + if (value === 'true') return true; + if (value === 'false') return false; + + return value; + } + + validateOperators(filters: Record): void { + const validOperators = ['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'in', 'contains', 'startsWith']; + + for (const [field, operators] of Object.entries(filters)) { + for (const operator of Object.keys(operators)) { + if (!validOperators.includes(operator)) { + throw new Error(`Invalid operator '${operator}' for field '${field}'`); + } + } + } + } +} \ No newline at end of file diff --git a/backend/src/search/PaginationHelper.ts b/backend/src/search/PaginationHelper.ts new file mode 100644 index 00000000..a5ef388a --- /dev/null +++ b/backend/src/search/PaginationHelper.ts @@ -0,0 +1,87 @@ +import { SearchOptions } from './SearchService.js'; + +export interface PaginationOptions { + limit: number; + offset?: number; + cursor?: string; +} + +export class PaginationHelper { + private readonly DEFAULT_LIMIT = 20; + private readonly MAX_LIMIT = 100; + + buildPaginationOptions(options: SearchOptions): PaginationOptions { + const limit = Math.min(options.limit || this.DEFAULT_LIMIT, this.MAX_LIMIT); + + if (options.cursor) { + return { + limit, + cursor: options.cursor, + }; + } else { + const page = options.page || 1; + const offset = (page - 1) * limit; + + return { + limit, + offset, + }; + } + } + + buildPaginationMetadata( + options: SearchOptions, + total: number, + resultCount: number + ): { + page?: number; + limit?: number; + total?: number; + hasMore?: boolean; + cursor?: string; + nextCursor?: string; + } { + if (options.cursor) { + // Cursor-based pagination + return { + cursor: options.cursor, + limit: options.limit || this.DEFAULT_LIMIT, + hasMore: resultCount === (options.limit || this.DEFAULT_LIMIT), + nextCursor: resultCount > 0 ? this.encodeCursor({ id: (resultCount as any).id }) : null, + }; + } else { + // Offset-based pagination + const page = options.page || 1; + const limit = options.limit || this.DEFAULT_LIMIT; + const totalPages = Math.ceil(total / limit); + + return { + page, + limit, + total, + hasMore: page < totalPages, + }; + } + } + + encodeCursor(data: Record): string { + return Buffer.from(JSON.stringify(data)).toString('base64'); + } + + decodeCursor(cursor: string): Record { + try { + return JSON.parse(Buffer.from(cursor, 'base64').toString()); + } catch { + throw new Error('Invalid cursor'); + } + } + + validateCursor(cursor: string): boolean { + try { + this.decodeCursor(cursor); + return true; + } catch { + return false; + } + } +} \ No newline at end of file diff --git a/backend/src/search/SearchQueryBuilder.ts b/backend/src/search/SearchQueryBuilder.ts new file mode 100644 index 00000000..655bb98c --- /dev/null +++ b/backend/src/search/SearchQueryBuilder.ts @@ -0,0 +1,140 @@ +import { Prisma } from '@prisma/client'; + +export class SearchQueryBuilder { + buildWhereClause(query?: string, searchFields: string[] = []): Prisma.StudentWhereInput | Prisma.CourseWhereInput | Prisma.CertificateWhereInput { + if (!query || searchFields.length === 0) { + return {}; + } + + const searchConditions = searchFields.map(field => ({ + [field]: { + contains: query, + mode: 'insensitive' as const, + }, + })); + + return { + OR: searchConditions, + } as any; + } + + buildFilterClause(filters: Record, filterFields: Record): Record { + const whereClause: Record = {}; + + for (const [key, value] of Object.entries(filters)) { + const fieldType = filterFields[key]; + if (!fieldType) continue; + + switch (fieldType) { + case 'string': + whereClause[key] = this.buildStringFilter(value); + break; + case 'number': + whereClause[key] = this.buildNumberFilter(value); + break; + case 'date': + whereClause[key] = this.buildDateFilter(value); + break; + case 'boolean': + whereClause[key] = value; + break; + case 'array': + whereClause[key] = this.buildArrayFilter(value); + break; + } + } + + return whereClause; + } + + private buildStringFilter(value: any): Record { + if (typeof value === 'string') { + return { equals: value, mode: 'insensitive' }; + } + + if (typeof value === 'object' && value !== null) { + const filter: Record = {}; + + if (value.eq) filter.equals = value.eq; + if (value.neq) filter.not = value.neq; + if (value.contains) filter.contains = value.contains; + if (value.startsWith) filter.startsWith = value.startsWith; + if (value.in) filter.in = value.in; + + if (Object.keys(filter).length > 0) { + filter.mode = 'insensitive'; + } + + return filter; + } + + return value; + } + + private buildNumberFilter(value: any): Record { + if (typeof value === 'number') { + return { equals: value }; + } + + if (typeof value === 'object' && value !== null) { + const filter: Record = {}; + + if (value.eq !== undefined) filter.equals = value.eq; + if (value.neq !== undefined) filter.not = value.neq; + if (value.gt !== undefined) filter.gt = value.gt; + if (value.gte !== undefined) filter.gte = value.gte; + if (value.lt !== undefined) filter.lt = value.lt; + if (value.lte !== undefined) filter.lte = value.lte; + if (value.in) filter.in = value.in; + + return filter; + } + + return value; + } + + private buildDateFilter(value: any): Record { + if (value instanceof Date || typeof value === 'string') { + return { equals: new Date(value) }; + } + + if (typeof value === 'object' && value !== null) { + const filter: Record = {}; + + if (value.eq) filter.equals = new Date(value.eq); + if (value.neq) filter.not = new Date(value.neq); + if (value.gt) filter.gt = new Date(value.gt); + if (value.gte) filter.gte = new Date(value.gte); + if (value.lt) filter.lt = new Date(value.lt); + if (value.lte) filter.lte = new Date(value.lte); + + return filter; + } + + return value; + } + + private buildArrayFilter(value: any): Record { + if (Array.isArray(value)) { + return { hasSome: value }; + } + + if (typeof value === 'object' && value !== null) { + if (value.in) return { hasSome: value.in }; + if (value.contains) return { has: value.contains }; + } + + return value; + } + + buildOrderByClause(sort?: string): Record[] { + if (!sort) { + return [{ createdAt: 'desc' }]; + } + + const [field, direction] = sort.split(':'); + const dir = direction === 'desc' ? 'desc' : 'asc'; + + return [{ [field]: dir }]; + } +} \ No newline at end of file diff --git a/backend/src/search/SearchService.ts b/backend/src/search/SearchService.ts new file mode 100644 index 00000000..cc242403 --- /dev/null +++ b/backend/src/search/SearchService.ts @@ -0,0 +1,119 @@ +import { PrismaClient } from '@prisma/client'; +import { PaginationHelper } from './PaginationHelper.js'; +import { SearchQueryBuilder } from './SearchQueryBuilder.js'; +import { SearchValidator } from './SearchValidator.js'; + +export interface SearchOptions { + query?: string; + filters?: Record; + sort?: string; + page?: number; + limit?: number; + cursor?: string; +} + +export interface SearchResult { + data: T[]; + pagination: { + page?: number; + limit?: number; + total?: number; + hasMore?: boolean; + cursor?: string; + nextCursor?: string; + }; + metadata: { + query?: string; + filters?: Record; + sortBy?: string; + executionTime: string; + totalResults: number; + }; +} + +export abstract class SearchService { + protected prisma: PrismaClient; + protected queryBuilder: SearchQueryBuilder; + protected validator: SearchValidator; + protected paginationHelper: PaginationHelper; + + constructor(prisma: PrismaClient) { + this.prisma = prisma; + this.queryBuilder = new SearchQueryBuilder(); + this.validator = new SearchValidator(); + this.paginationHelper = new PaginationHelper(); + } + + abstract search(options: SearchOptions): Promise>; + abstract getEntityName(): string; + + protected async executeSearch( + model: any, + options: SearchOptions, + searchFields: string[], + filterFields: Record + ): Promise> { + const startTime = Date.now(); + + // Validate search options + this.validator.validateSearchOptions(options); + + // Build where clause for full-text search + const whereClause = this.queryBuilder.buildWhereClause(options.query, searchFields); + + // Add filters + const filterWhere = this.queryBuilder.buildFilterClause(options.filters || {}, filterFields); + Object.assign(whereClause, filterWhere); + + // Build order by clause + const orderBy = this.queryBuilder.buildOrderByClause(options.sort); + + // Execute count query for pagination metadata + const total = await model.count({ where: whereClause }); + + // Build pagination + const paginationOptions = this.paginationHelper.buildPaginationOptions(options); + + // Execute search query + let data: T[]; + if (paginationOptions.cursor) { + // Cursor-based pagination + data = await model.findMany({ + where: whereClause, + orderBy, + take: paginationOptions.limit, + skip: paginationOptions.cursor ? 1 : 0, + cursor: paginationOptions.cursor ? { id: paginationOptions.cursor } : undefined, + }); + } else { + // Offset-based pagination + data = await model.findMany({ + where: whereClause, + orderBy, + take: paginationOptions.limit, + skip: paginationOptions.offset, + }); + } + + const executionTime = `${Date.now() - startTime}ms`; + + // Build pagination metadata + const pagination = this.paginationHelper.buildPaginationMetadata( + options, + total, + data.length + ); + + return { + data, + pagination, + metadata: { + query: options.query, + filters: options.filters, + sortBy: options.sort, + executionTime, + totalResults: total, + }, + }; + } +} \ No newline at end of file diff --git a/backend/src/search/SearchValidator.ts b/backend/src/search/SearchValidator.ts new file mode 100644 index 00000000..8dc6a300 --- /dev/null +++ b/backend/src/search/SearchValidator.ts @@ -0,0 +1,61 @@ +import { z } from 'zod'; + +export interface SearchOptions { + query?: string; + filters?: Record; + sort?: string; + page?: number; + limit?: number; + cursor?: string; +} + +const searchOptionsSchema = 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"], + } +); + +export class SearchValidator { + validateSearchOptions(options: SearchOptions): void { + try { + searchOptionsSchema.parse(options); + } catch (error) { + if (error instanceof z.ZodError) { + throw new Error(`Invalid search options: ${error.errors.map(e => e.message).join(', ')}`); + } + throw error; + } + } + + validateSortField(sort: string, allowedFields: string[]): void { + const [field] = sort.split(':'); + if (!allowedFields.includes(field)) { + throw new Error(`Invalid sort field: ${field}. Allowed fields: ${allowedFields.join(', ')}`); + } + } + + validateFilterFields(filters: Record, allowedFields: string[]): void { + for (const field of Object.keys(filters)) { + if (!allowedFields.includes(field)) { + throw new Error(`Invalid filter field: ${field}. Allowed fields: ${allowedFields.join(', ')}`); + } + } + } + + sanitizeQuery(query: string): string { + // Remove potentially harmful characters and limit length + return query + .replace(/[<>'"&]/g, '') + .trim() + .substring(0, 1000); + } +} \ No newline at end of file diff --git a/backend/src/search/StudentSearchService.ts b/backend/src/search/StudentSearchService.ts new file mode 100644 index 00000000..226281be --- /dev/null +++ b/backend/src/search/StudentSearchService.ts @@ -0,0 +1,39 @@ +import { PrismaClient } from '@prisma/client'; +import { SearchOptions, SearchResult, SearchService } from './SearchService.js'; + +export class StudentSearchService extends SearchService { + private readonly searchFields = ['firstName', 'lastName', 'email']; + private readonly filterFields = { + email: 'string', + createdAt: 'date', + did: 'string', + }; + private readonly sortFields = ['firstName', 'lastName', 'email', 'createdAt']; + + constructor(prisma: PrismaClient) { + super(prisma); + } + + async search(options: SearchOptions): Promise> { + // 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.student, + options, + this.searchFields, + this.filterFields + ); + } + + getEntityName(): string { + return 'students'; + } +} \ No newline at end of file