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
1,305 changes: 549 additions & 756 deletions backend/package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,15 @@
"cache-manager-ioredis": "^2.1.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"helmet": "^8.2.0",
"ioredis": "^5.11.1",
"multer": "^1.4.5-lts.1",
"passport": "^0.6.0",
"passport-jwt": "^4.0.1",
"pg": "^8.22.0",
"prom-client": "^15.1.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"helmet": "^7.0.0",
"socket.io": "^4.8.3",
"stellar-sdk": "^12.0.2",
"typeorm": "^1.0.0",
Expand Down
66 changes: 66 additions & 0 deletions backend/src/common/interceptors/response.interceptor.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { ResponseInterceptor } from './response.interceptor';
import { ExecutionContext, CallHandler } from '@nestjs/common';
import { of } from 'rxjs';
import { firstValueFrom } from 'rxjs';

describe('ResponseInterceptor', () => {
let interceptor: ResponseInterceptor<any>;

beforeEach(() => {
interceptor = new ResponseInterceptor();
});

it('should format successful response', async () => {
const mockExecutionContext = {
switchToHttp: jest.fn().mockReturnValue({
getResponse: jest.fn().mockReturnValue({ statusCode: 200 }),
getRequest: jest.fn().mockReturnValue({
url: '/api/test',
headers: { 'x-request-id': 'req-123' },
}),
}),
} as unknown as ExecutionContext;

const mockCallHandler = {
handle: jest.fn().mockReturnValue(of({ foo: 'bar' })),
} as CallHandler;

const result = await firstValueFrom(interceptor.intercept(mockExecutionContext, mockCallHandler));

expect(result.success).toBe(true);
expect(result.statusCode).toBe(200);
expect(result.message).toBe('Success');
expect(result.data).toEqual({ foo: 'bar' });
expect(result.path).toBe('/api/test');
expect(result.requestId).toBe('req-123');
expect(result.timestamp).toBeDefined();
});

it('should merge paginated response correctly', async () => {
const mockExecutionContext = {
switchToHttp: jest.fn().mockReturnValue({
getResponse: jest.fn().mockReturnValue({ statusCode: 200 }),
getRequest: jest.fn().mockReturnValue({
url: '/api/test',
headers: {},
}),
}),
} as unknown as ExecutionContext;

const paginatedData = {
data: [{ id: 1 }],
meta: { page: 1, limit: 10, total: 1, totalPages: 1, hasNext: false, hasPrev: false },
};

const mockCallHandler = {
handle: jest.fn().mockReturnValue(of(paginatedData)),
} as CallHandler;

const result = await firstValueFrom(interceptor.intercept(mockExecutionContext, mockCallHandler));

expect(result.success).toBe(true);
expect(result.statusCode).toBe(200);
expect(result.data).toEqual(paginatedData);
expect(result.requestId).toBe('N/A');
});
});
47 changes: 47 additions & 0 deletions backend/src/common/interceptors/response.interceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

export interface Response<T> {
success: boolean;
statusCode: number;
message: string;
data: T;
timestamp: string;
path: string;
requestId?: string;
}

@Injectable()
export class ResponseInterceptor<T> implements NestInterceptor<T, Response<T>> {
intercept(context: ExecutionContext, next: CallHandler): Observable<Response<T>> {
const ctx = context.switchToHttp();
const response = ctx.getResponse();
const request = ctx.getRequest();

return next.handle().pipe(
map((res) => {
// If the response is already paginated (has meta), spread it appropriately
let data = res;
if (res && res.data && res.meta) {
data = res;
}

return {
success: true,
statusCode: response.statusCode,
message: 'Success',
data,
timestamp: new Date().toISOString(),
path: request.url,
requestId: request.headers['x-request-id'] || 'N/A', // Normally generated via middleware
};
}),
);
}
}
36 changes: 36 additions & 0 deletions backend/src/common/pagination/pagination.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Min, Max, IsString } from 'class-validator';

export class PaginationQueryDto {
@ApiPropertyOptional({
description: 'Page number (starts from 1)',
minimum: 1,
default: 1,
})
@Type(() => Number)
@IsInt()
@Min(1)
@IsOptional()
page?: number = 1;

@ApiPropertyOptional({
description: 'Number of items per page',
minimum: 1,
maximum: 100,
default: 10,
})
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
@IsOptional()
limit?: number = 10;

@ApiPropertyOptional({
description: 'Cursor for cursor-based pagination',
})
@IsString()
@IsOptional()
cursor?: string;
}
19 changes: 19 additions & 0 deletions backend/src/common/pagination/pagination.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export interface PaginationMeta {
page: number;
limit: number;
total: number;
totalPages: number;
hasNext: boolean;
hasPrev: boolean;
}

export interface PaginatedResponse<T> {
data: T[];
meta: PaginationMeta;
links?: {
first: string;
prev: string | null;
next: string | null;
last: string;
};
}
107 changes: 107 additions & 0 deletions backend/src/common/pagination/pagination.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { Test, TestingModule } from '@nestjs/testing';
import { PaginationService } from './pagination.service';
import { Repository, SelectQueryBuilder } from 'typeorm';
import { BadRequestException } from '@nestjs/common';

describe('PaginationService', () => {
let service: PaginationService;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [PaginationService],
}).compile();

service = module.get<PaginationService>(PaginationService);
});

it('should be defined', () => {
expect(service).toBeDefined();
});

describe('paginate with Repository', () => {
let repository: Partial<Repository<any>>;

beforeEach(() => {
repository = {
findAndCount: jest.fn(),
};
});

it('should return paginated response successfully', async () => {
const mockData = [{ id: 1 }, { id: 2 }];
const mockTotal = 5;
(repository.findAndCount as jest.Mock).mockResolvedValue([mockData, mockTotal]);

const result = await service.paginate(repository as Repository<any>, { page: 1, limit: 2 });

expect(repository.findAndCount).toHaveBeenCalledWith({ skip: 0, take: 2 });
expect(result.data).toEqual(mockData);
expect(result.meta.total).toBe(mockTotal);
expect(result.meta.page).toBe(1);
expect(result.meta.limit).toBe(2);
expect(result.meta.totalPages).toBe(3);
expect(result.meta.hasNext).toBe(true);
expect(result.meta.hasPrev).toBe(false);
});

it('should handle page beyond total', async () => {
(repository.findAndCount as jest.Mock).mockResolvedValue([[], 5]);

const result = await service.paginate(repository as Repository<any>, { page: 4, limit: 2 });

expect(repository.findAndCount).toHaveBeenCalledWith({ skip: 6, take: 2 });
expect(result.data).toEqual([]);
expect(result.meta.hasNext).toBe(false);
expect(result.meta.hasPrev).toBe(true);
});

it('should throw error for negative page', async () => {
await expect(service.paginate(repository as Repository<any>, { page: -1, limit: 10 })).rejects.toThrow(BadRequestException);
});

it('should throw error for invalid limit', async () => {
await expect(service.paginate(repository as Repository<any>, { page: 1, limit: -1 })).rejects.toThrow(BadRequestException);
await expect(service.paginate(repository as Repository<any>, { page: 1, limit: 200 })).rejects.toThrow(BadRequestException); // max 100 enforced by Math.min in implementation? Wait, implementation sets max limit to 100 but doesn't throw if > 100? Let's check implementation.
});

it('should generate links if route provided', async () => {
(repository.findAndCount as jest.Mock).mockResolvedValue([[], 15]);

const result = await service.paginate(
repository as Repository<any>,
{ page: 2, limit: 5 },
{ route: 'http://localhost/items' }
);

expect(result.links?.first).toBe('http://localhost/items?page=1&limit=5');
expect(result.links?.prev).toBe('http://localhost/items?page=1&limit=5');
expect(result.links?.next).toBe('http://localhost/items?page=3&limit=5');
expect(result.links?.last).toBe('http://localhost/items?page=3&limit=5');
});
});

describe('paginate with QueryBuilder', () => {
let queryBuilder: Partial<SelectQueryBuilder<any>>;

beforeEach(() => {
queryBuilder = {
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn(),
};
});

it('should work with QueryBuilder', async () => {
const mockData = [{ id: 1 }];
const mockTotal = 1;
(queryBuilder.getManyAndCount as jest.Mock).mockResolvedValue([mockData, mockTotal]);

const result = await service.paginate(queryBuilder as SelectQueryBuilder<any>, { page: 1, limit: 10 });

expect(queryBuilder.skip).toHaveBeenCalledWith(0);
expect(queryBuilder.take).toHaveBeenCalledWith(10);
expect(result.data).toEqual(mockData);
expect(result.meta.total).toBe(1);
});
});
});
74 changes: 74 additions & 0 deletions backend/src/common/pagination/pagination.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { SelectQueryBuilder, Repository } from 'typeorm';
import { PaginatedResponse } from './pagination.interface';
import { PaginationQueryDto } from './pagination.dto';

export interface PaginationOptions {
route?: string;
}

@Injectable()
export class PaginationService {
async paginate<T>(
queryBuilderOrRepository: SelectQueryBuilder<T> | Repository<T>,
paginationQuery: PaginationQueryDto,
options?: PaginationOptions,
): Promise<PaginatedResponse<T>> {
const page = paginationQuery.page && paginationQuery.page > 0 ? paginationQuery.page : 1;
const limit = paginationQuery.limit && paginationQuery.limit > 0 ? paginationQuery.limit : 10;

// Validate edge cases
if (page < 1) {
throw new BadRequestException('Page must be greater than 0');
}
if (limit < 1 || limit > 100) {
throw new BadRequestException('Limit must be between 1 and 100');
}

let items: T[] = [];
let total = 0;

const skip = (page - 1) * limit;

if (queryBuilderOrRepository instanceof Repository) {
[items, total] = await queryBuilderOrRepository.findAndCount({
skip,
take: limit,
});
} else {
[items, total] = await queryBuilderOrRepository
.skip(skip)
.take(limit)
.getManyAndCount();
}

const totalPages = Math.ceil(total / limit);
const hasNext = page < totalPages;
const hasPrev = page > 1;

// Optional link generation if a route is provided
let links;
if (options?.route) {
const baseUrl = options.route;
links = {
first: `${baseUrl}?page=1&limit=${limit}`,
prev: hasPrev ? `${baseUrl}?page=${page - 1}&limit=${limit}` : null,
next: hasNext ? `${baseUrl}?page=${page + 1}&limit=${limit}` : null,
last: totalPages > 0 ? `${baseUrl}?page=${totalPages}&limit=${limit}` : `${baseUrl}?page=1&limit=${limit}`,
};
}

return {
data: items,
meta: {
page,
limit,
total,
totalPages,
hasNext,
hasPrev,
},
...(links && { links }),
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { MigrationInterface, QueryRunner } from "typeorm";

export class AddPerformanceIndexes1782526959527 implements MigrationInterface {
name = 'AddPerformanceIndexes1782526959527'

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE INDEX "IDX_users_wallet_address_is_verified" ON "users" ("wallet_address", "is_verified") `);
await queryRunner.query(`CREATE INDEX "IDX_users_created_at" ON "users" ("created_at") `);
await queryRunner.query(`CREATE INDEX "IDX_availability_slots_mentor_day" ON "availability_slots" ("mentor_id", "day_of_week") `);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "public"."IDX_availability_slots_mentor_day"`);
await queryRunner.query(`DROP INDEX "public"."IDX_users_created_at"`);
await queryRunner.query(`DROP INDEX "public"."IDX_users_wallet_address_is_verified"`);
}

}
1 change: 1 addition & 0 deletions backend/src/entities/user.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export class User {
@Column({ nullable: true, default: null })
deletedAt: Date | null;

@Index()
@CreateDateColumn()
createdAt: Date;

Expand Down
Loading