diff --git a/backend/src/common/pagination.ts b/backend/src/common/pagination.ts new file mode 100644 index 0000000..47e2b75 --- /dev/null +++ b/backend/src/common/pagination.ts @@ -0,0 +1,26 @@ +export const DEFAULT_PAGE_LIMIT = 20; +export const MAX_PAGE_LIMIT = 100; + +export interface PaginatedResponse { + items: T[]; + total: number; + page: number; + limit: number; +} + +export interface PaginationQuery { + page?: number; + limit?: number; +} + +export function paginate(items: T[], query: PaginationQuery): PaginatedResponse { + const limit = Math.min(query.limit ?? DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT); + const page = query.page ?? 1; + const start = (page - 1) * limit; + return { + items: items.slice(start, start + limit), + total: items.length, + page, + limit, + }; +} diff --git a/backend/src/escrow/escrow.controller.spec.ts b/backend/src/escrow/escrow.controller.spec.ts index c867ea0..e8e8219 100644 --- a/backend/src/escrow/escrow.controller.spec.ts +++ b/backend/src/escrow/escrow.controller.spec.ts @@ -100,6 +100,66 @@ describe('EscrowController', () => { expect(controller).toBeDefined(); }); + describe('findByDepositor', () => { + it('returns paginated results with default offset=0, limit=20', async () => { + const escrows = Array.from({ length: 5 }, (_, i) => ({ + id: `esc-${i}`, + depositor: VALID_ADDRESS, + beneficiary: 'GBEN', + amountXLM: '100', + status: 'pending', + createdAt: new Date().toISOString(), + })); + mockEscrowService.findByDepositor.mockResolvedValue({ data: escrows, total: 5 }); + + const result = await controller.findByDepositor(VALID_ADDRESS); + + expect(mockEscrowService.findByDepositor).toHaveBeenCalledWith(VALID_ADDRESS, 0, 20); + expect(result).toEqual({ data: escrows, total: 5 }); + }); + + it('applies custom offset and limit', async () => { + mockEscrowService.findByDepositor.mockResolvedValue({ data: [], total: 0 }); + + await controller.findByDepositor(VALID_ADDRESS, 10, 5); + + expect(mockEscrowService.findByDepositor).toHaveBeenCalledWith(VALID_ADDRESS, 10, 5); + }); + + it('clamps limit to max 100', async () => { + mockEscrowService.findByDepositor.mockResolvedValue({ data: [], total: 0 }); + + await controller.findByDepositor(VALID_ADDRESS, 0, 200); + + expect(mockEscrowService.findByDepositor).toHaveBeenCalledWith(VALID_ADDRESS, 0, 100); + }); + + it('clamps negative offset to 0', async () => { + mockEscrowService.findByDepositor.mockResolvedValue({ data: [], total: 0 }); + + await controller.findByDepositor(VALID_ADDRESS, -5, 10); + + expect(mockEscrowService.findByDepositor).toHaveBeenCalledWith(VALID_ADDRESS, 0, 10); + }); + }); + + describe('release', () => { + it('releases the escrow and records the completion with the reputation engine', async () => { + const escrow = { + id: 'esc-1', + depositor: 'GDEP', + beneficiary: 'GBEN', + amountXLM: '100', + status: 'released', + createdAt: new Date().toISOString(), + }; + mockEscrowService.release.mockResolvedValue(escrow); + + const result = await controller.release('esc-1'); + + expect(mockEscrowService.release).toHaveBeenCalledWith('esc-1'); + expect(mockReputationService.recordEscrowCompleted).toHaveBeenCalledWith(escrow); + expect(result).toEqual(escrow); // ─── POST /escrows (create) ─────────────────────────────────────────────── describe('create()', () => { diff --git a/backend/src/escrow/escrow.controller.ts b/backend/src/escrow/escrow.controller.ts index 3748a06..beb7043 100644 --- a/backend/src/escrow/escrow.controller.ts +++ b/backend/src/escrow/escrow.controller.ts @@ -131,33 +131,57 @@ export class EscrowController { @Get('depositor/:address') @ApiOperation({ summary: 'Get escrows by depositor', - description: 'Retrieves all escrows created by a specific depositor address.', + description: 'Retrieves escrows created by a specific depositor address with pagination.', }) @ApiParam({ name: 'address', description: 'Stellar address of the depositor', example: 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', }) + @ApiQuery({ + name: 'offset', + required: false, + type: Number, + description: 'Number of items to skip. Defaults to 0.', + }) + @ApiQuery({ + name: 'limit', + required: false, + type: Number, + description: 'Maximum number of items to return (max 100). Defaults to 20.', + }) @ApiResponse({ status: 200, - description: 'List of escrows', + description: 'Paginated list of escrows', schema: { - type: 'array', - items: { - type: 'object', - properties: { - id: { type: 'string' }, - depositor: { type: 'string' }, - beneficiary: { type: 'string' }, - amountXLM: { type: 'string' }, - status: { type: 'string' }, - createdAt: { type: 'string', format: 'date-time' }, + type: 'object', + properties: { + data: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string' }, + depositor: { type: 'string' }, + beneficiary: { type: 'string' }, + amountXLM: { type: 'string' }, + status: { type: 'string' }, + createdAt: { type: 'string', format: 'date-time' }, + }, + }, }, + total: { type: 'number' }, }, }, }) - findByDepositor(@Param('address') address: string) { - return this.escrowService.findByDepositor(address); + findByDepositor( + @Param('address') address: string, + @Query('offset') offset?: number, + @Query('limit') limit?: number, + ) { + const safeOffset = Math.max(0, Number(offset) || 0); + const safeLimit = Math.min(Math.max(1, Number(limit) || 20), 100); + return this.escrowService.findByDepositor(address, safeOffset, safeLimit); } @Post(':id/release') diff --git a/backend/src/escrow/escrow.service.spec.ts b/backend/src/escrow/escrow.service.spec.ts index cde429f..c719dc4 100644 --- a/backend/src/escrow/escrow.service.spec.ts +++ b/backend/src/escrow/escrow.service.spec.ts @@ -18,6 +18,22 @@ describe('EscrowService', () => { service = module.get(EscrowService); }); + describe('create', () => { + it('generates unique IDs even when called concurrently in a tight loop', async () => { + const numEscrows = 1000; + const promises: Promise[] = []; + for (let i = 0; i < numEscrows; i++) { + promises.push(service.create(`GDEP${i}`, `GBEN${i}`, '100')); + } + + const escrows = await Promise.all(promises); + const ids = new Set(escrows.map(e => e.id)); + + expect(ids.size).toBe(numEscrows); + + // Verify UUID format (basic check) + const sampleId = escrows[0].id; + expect(sampleId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i); it('should be defined', () => { expect(service).toBeDefined(); }); @@ -95,6 +111,43 @@ describe('EscrowService', () => { }); }); + describe('findByDepositor', () => { + it('returns paginated results for a depositor', async () => { + for (let i = 0; i < 5; i++) { + await service.create('GDEP', 'GBEN', '100'); + } + await service.create('GOTHER', 'GBEN', '200'); + + const result = await service.findByDepositor('GDEP', 0, 3); + + expect(result.total).toBe(5); + expect(result.data).toHaveLength(3); + }); + + it('returns empty data when offset exceeds total', async () => { + await service.create('GDEP', 'GBEN', '100'); + + const result = await service.findByDepositor('GDEP', 10, 20); + + expect(result.total).toBe(1); + expect(result.data).toHaveLength(0); + }); + + it('returns empty data for unknown depositor', async () => { + await service.create('GDEP', 'GBEN', '100'); + + const result = await service.findByDepositor('GUNKNOWN', 0, 20); + + expect(result.total).toBe(0); + expect(result.data).toHaveLength(0); + }); + }); + + describe('fund', () => { + it('updates status to active for a pending escrow', async () => { + const escrow = await service.create('GDEP', 'GBEN', '100'); + const updated = await service.fund(escrow.id); + expect(updated.status).toBe('active'); // ─── release() ──────────────────────────────────────────────────────────── describe('release()', () => { diff --git a/backend/src/escrow/escrow.service.ts b/backend/src/escrow/escrow.service.ts index 8bc8a97..06aaf5e 100644 --- a/backend/src/escrow/escrow.service.ts +++ b/backend/src/escrow/escrow.service.ts @@ -54,8 +54,15 @@ export class EscrowService { return this.escrows.get(id); } - async findByDepositor(address: string): Promise { - return [...this.escrows.values()].filter(e => e.depositor === address); + async findByDepositor( + address: string, + offset = 0, + limit = 20, + ): Promise<{ data: Escrow[]; total: number }> { + const all = [...this.escrows.values()].filter(e => e.depositor === address); + const total = all.length; + const data = all.slice(offset, offset + limit); + return { data, total }; } async findAll(): Promise { diff --git a/backend/src/gig/gig.controller.spec.ts b/backend/src/gig/gig.controller.spec.ts index c149e98..3951674 100644 --- a/backend/src/gig/gig.controller.spec.ts +++ b/backend/src/gig/gig.controller.spec.ts @@ -102,12 +102,50 @@ describe('GigController', () => { }); describe('findByCreator', () => { - it('delegates to the service', async () => { - mockGigService.findByCreator.mockResolvedValue([{ id: 'gig-1' }]); + it('delegates to the service with default pagination', async () => { + mockGigService.findByCreator.mockResolvedValue({ data: [{ id: 'gig-1' }], total: 1 }); const address = 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'; - await expect(controller.findByCreator(address)).resolves.toEqual([{ id: 'gig-1' }]); - expect(mockGigService.findByCreator).toHaveBeenCalledWith(address); + const result = await controller.findByCreator(address); + + expect(result).toEqual({ data: [{ id: 'gig-1' }], total: 1 }); + expect(mockGigService.findByCreator).toHaveBeenCalledWith(address, { + status: undefined, + minBudgetXLM: undefined, + maxBudgetXLM: undefined, + offset: 0, + limit: 20, + }); + }); + + it('applies custom pagination and filters', async () => { + mockGigService.findByCreator.mockResolvedValue({ data: [], total: 0 }); + const address = 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'; + + await controller.findByCreator(address, GigStatus.OPEN, '10', '100', 5, 10); + + expect(mockGigService.findByCreator).toHaveBeenCalledWith(address, { + status: GigStatus.OPEN, + minBudgetXLM: '10', + maxBudgetXLM: '100', + offset: 5, + limit: 10, + }); + }); + + it('clamps limit to max 100', async () => { + mockGigService.findByCreator.mockResolvedValue({ data: [], total: 0 }); + const address = 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'; + + await controller.findByCreator(address, undefined, undefined, undefined, 0, 500); + + expect(mockGigService.findByCreator).toHaveBeenCalledWith(address, { + status: undefined, + minBudgetXLM: undefined, + maxBudgetXLM: undefined, + offset: 0, + limit: 100, + }); }); }); diff --git a/backend/src/gig/gig.controller.ts b/backend/src/gig/gig.controller.ts index 60ae874..9173ed7 100644 --- a/backend/src/gig/gig.controller.ts +++ b/backend/src/gig/gig.controller.ts @@ -128,6 +128,18 @@ export class GigController { type: Number, description: 'Results per page, up to 100. Defaults to 20.', }) + @ApiQuery({ + name: 'minBudgetXLM', + required: false, + type: String, + description: 'Minimum budget in XLM.', + }) + @ApiQuery({ + name: 'maxBudgetXLM', + required: false, + type: String, + description: 'Maximum budget in XLM.', + }) @ApiResponse({ status: 200, description: 'Paginated gig solicitations', @@ -159,15 +171,73 @@ export class GigController { } @Get('creator/:address') - @ApiOperation({ summary: 'List gig solicitations posted by a creator' }) + @ApiOperation({ + summary: 'List gig solicitations posted by a creator', + description: 'Retrieves gig solicitations by a creator with optional filtering and pagination.', + }) @ApiParam({ name: 'address', description: 'Stellar address of the gig creator', example: 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', }) - @ApiResponse({ status: 200, description: 'List of gig solicitations' }) - findByCreator(@Param('address') address: string) { - return this.gigService.findByCreator(address); + @ApiQuery({ + name: 'status', + required: false, + enum: GigStatus, + description: 'Filter by gig status.', + }) + @ApiQuery({ + name: 'minBudgetXLM', + required: false, + type: String, + description: 'Minimum budget in XLM.', + }) + @ApiQuery({ + name: 'maxBudgetXLM', + required: false, + type: String, + description: 'Maximum budget in XLM.', + }) + @ApiQuery({ + name: 'offset', + required: false, + type: Number, + description: 'Number of items to skip. Defaults to 0.', + }) + @ApiQuery({ + name: 'limit', + required: false, + type: Number, + description: 'Maximum number of items to return (max 100). Defaults to 20.', + }) + @ApiResponse({ + status: 200, + description: 'Paginated list of gig solicitations', + schema: { + type: 'object', + properties: { + data: { type: 'array', items: { type: 'object' } }, + total: { type: 'number' }, + }, + }, + }) + findByCreator( + @Param('address') address: string, + @Query('status') status?: GigStatus, + @Query('minBudgetXLM') minBudgetXLM?: string, + @Query('maxBudgetXLM') maxBudgetXLM?: string, + @Query('offset') offset?: number, + @Query('limit') limit?: number, + ) { + const safeOffset = Math.max(0, Number(offset) || 0); + const safeLimit = Math.min(Math.max(1, Number(limit) || 20), 100); + return this.gigService.findByCreator(address, { + status, + minBudgetXLM, + maxBudgetXLM, + offset: safeOffset, + limit: safeLimit, + }); } @Post(':id/accept') diff --git a/backend/src/gig/gig.dto.ts b/backend/src/gig/gig.dto.ts index e2a59a6..e69fd95 100644 --- a/backend/src/gig/gig.dto.ts +++ b/backend/src/gig/gig.dto.ts @@ -45,6 +45,8 @@ export const SearchGigsSchema = z.object({ status: z.nativeEnum(GigStatus).optional(), page: z.coerce.number().int().positive().optional(), limit: z.coerce.number().int().positive().max(MAX_GIG_SEARCH_LIMIT).optional(), + minBudgetXLM: z.coerce.string().regex(/^\d+(\.\d{1,7})?$/, 'Invalid XLM amount').optional(), + maxBudgetXLM: z.coerce.string().regex(/^\d+(\.\d{1,7})?$/, 'Invalid XLM amount').optional(), }); export type SearchGigsQuery = z.infer; diff --git a/backend/src/gig/gig.entity.ts b/backend/src/gig/gig.entity.ts index 63e7ec3..8beb17f 100644 --- a/backend/src/gig/gig.entity.ts +++ b/backend/src/gig/gig.entity.ts @@ -49,3 +49,7 @@ export const DEFAULT_GIG_SEARCH_LIMIT = 20; export const MAX_GIG_SEARCH_LIMIT = 100; /** How long a paginated search result page stays cached in Redis. */ export const DEFAULT_GIG_SEARCH_CACHE_TTL_SECONDS = 30; + +export const DEFAULT_GIG_BY_CREATOR_OFFSET = 0; +export const DEFAULT_GIG_BY_CREATOR_LIMIT = 20; +export const MAX_GIG_BY_CREATOR_LIMIT = 100; diff --git a/backend/src/gig/gig.service.spec.ts b/backend/src/gig/gig.service.spec.ts index bbd527e..f498a62 100644 --- a/backend/src/gig/gig.service.spec.ts +++ b/backend/src/gig/gig.service.spec.ts @@ -175,8 +175,64 @@ describe('GigService', () => { creator: 'GYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY', }); - const results = await service.findByCreator(validDto.creator); - expect(results).toEqual([gig]); + const result = await service.findByCreator(validDto.creator); + expect(result.data).toEqual([gig]); + expect(result.total).toBe(1); + }); + + it('returns paginated results', async () => { + for (let i = 0; i < 5; i++) { + await service.create({ ...validDto, title: `Gig ${i}` }); + } + + const page1 = await service.findByCreator(validDto.creator, { offset: 0, limit: 2 }); + const page2 = await service.findByCreator(validDto.creator, { offset: 2, limit: 2 }); + + expect(page1.data).toHaveLength(2); + expect(page2.data).toHaveLength(2); + expect(page1.total).toBe(5); + expect(page1.data[0].id).not.toBe(page2.data[0].id); + }); + + it('filters by status', async () => { + const gig = await service.create(validDto); + await service.accept(gig.id, 'GYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY'); + await service.create({ ...validDto, title: 'Second gig' }); + + const result = await service.findByCreator(validDto.creator, { status: GigStatus.OPEN }); + expect(result.data).toHaveLength(1); + expect(result.data[0].status).toBe(GigStatus.OPEN); + }); + + it('filters by minBudgetXLM', async () => { + await service.create({ ...validDto, budgetXLM: '50' }); + await service.create({ ...validDto, budgetXLM: '200', title: 'Expensive gig' }); + + const result = await service.findByCreator(validDto.creator, { minBudgetXLM: '100' }); + expect(result.data).toHaveLength(1); + expect(result.data[0].budgetXLM).toBe('200'); + }); + + it('filters by maxBudgetXLM', async () => { + await service.create({ ...validDto, budgetXLM: '50' }); + await service.create({ ...validDto, budgetXLM: '200', title: 'Expensive gig' }); + + const result = await service.findByCreator(validDto.creator, { maxBudgetXLM: '100' }); + expect(result.data).toHaveLength(1); + expect(result.data[0].budgetXLM).toBe('50'); + }); + + it('filters by budget range', async () => { + await service.create({ ...validDto, budgetXLM: '10' }); + await service.create({ ...validDto, budgetXLM: '50', title: 'Mid gig' }); + await service.create({ ...validDto, budgetXLM: '200', title: 'Expensive gig' }); + + const result = await service.findByCreator(validDto.creator, { + minBudgetXLM: '25', + maxBudgetXLM: '100', + }); + expect(result.data).toHaveLength(1); + expect(result.data[0].budgetXLM).toBe('50'); }); }); @@ -260,6 +316,37 @@ describe('GigService', () => { expect(result.items).toEqual([]); expect(result.total).toBe(1); }); + + it('filters by minBudgetXLM', async () => { + await service.create({ ...validDto, budgetXLM: '50' }); + await service.create({ ...validDto, budgetXLM: '200', title: 'Expensive gig' }); + + const result = await service.search({ minBudgetXLM: '100' }); + + expect(result.items).toHaveLength(1); + expect(result.items[0].budgetXLM).toBe('200'); + }); + + it('filters by maxBudgetXLM', async () => { + await service.create({ ...validDto, budgetXLM: '50' }); + await service.create({ ...validDto, budgetXLM: '200', title: 'Expensive gig' }); + + const result = await service.search({ maxBudgetXLM: '100' }); + + expect(result.items).toHaveLength(1); + expect(result.items[0].budgetXLM).toBe('50'); + }); + + it('filters by budget range', async () => { + await service.create({ ...validDto, budgetXLM: '10' }); + await service.create({ ...validDto, budgetXLM: '50', title: 'Mid gig' }); + await service.create({ ...validDto, budgetXLM: '200', title: 'Expensive gig' }); + + const result = await service.search({ minBudgetXLM: '25', maxBudgetXLM: '100' }); + + expect(result.items).toHaveLength(1); + expect(result.items[0].budgetXLM).toBe('50'); + }); }); describe('accept', () => { diff --git a/backend/src/gig/gig.service.ts b/backend/src/gig/gig.service.ts index 477082c..f0129bf 100644 --- a/backend/src/gig/gig.service.ts +++ b/backend/src/gig/gig.service.ts @@ -131,17 +131,40 @@ export class GigService implements OnModuleInit { return gig; } - async findByCreator(address: string): Promise { + async findByCreator( + address: string, + options?: { status?: GigStatus; minBudgetXLM?: string; maxBudgetXLM?: string; offset?: number; limit?: number }, + ): Promise<{ data: Gig[]; total: number }> { + let gigs: Gig[]; if (this.redis) { try { const ids = await this.redis.smembers(this.creatorKey(address)); - return await this.fetchMany(ids); + gigs = await this.fetchMany(ids); } catch (err) { this.logFallback('findByCreator', err); + gigs = [...this.gigs.values()].filter(g => g.creator === address); } + } else { + gigs = [...this.gigs.values()].filter(g => g.creator === address); + } + + if (options?.status) { + gigs = gigs.filter(g => g.status === options.status); + } + if (options?.minBudgetXLM !== undefined) { + const min = parseFloat(options.minBudgetXLM); + gigs = gigs.filter(g => parseFloat(g.budgetXLM) >= min); + } + if (options?.maxBudgetXLM !== undefined) { + const max = parseFloat(options.maxBudgetXLM); + gigs = gigs.filter(g => parseFloat(g.budgetXLM) <= max); } - return [...this.gigs.values()].filter(g => g.creator === address); + const total = gigs.length; + const offset = options?.offset ?? 0; + const limit = options?.limit ?? 20; + const data = gigs.slice(offset, offset + limit); + return { data, total }; } /** Open gig solicitations whose response deadline has passed as of `now`. */ @@ -176,13 +199,24 @@ export class GigService implements OnModuleInit { if (cached) return cached; const all = await this.findAll(); - const filtered = all + let filtered = all .filter(g => g.status === status) .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + + if (query.minBudgetXLM !== undefined) { + const min = parseFloat(query.minBudgetXLM); + filtered = filtered.filter(g => parseFloat(g.budgetXLM) >= min); + } + if (query.maxBudgetXLM !== undefined) { + const max = parseFloat(query.maxBudgetXLM); + filtered = filtered.filter(g => parseFloat(g.budgetXLM) <= max); + } + + const total = filtered.length; const start = (page - 1) * limit; const result: PaginatedGigs = { items: filtered.slice(start, start + limit), - total: filtered.length, + total, page, limit, }; diff --git a/backend/src/user-profile/user-profile.controller.ts b/backend/src/user-profile/user-profile.controller.ts index 8c2986b..3baa7fe 100644 --- a/backend/src/user-profile/user-profile.controller.ts +++ b/backend/src/user-profile/user-profile.controller.ts @@ -177,7 +177,7 @@ export class UserProfileController { @Get() @ApiOperation({ summary: 'Get all user profiles', - description: 'Retrieves all user profiles with optional filtering.', + description: 'Retrieves all user profiles with optional filtering and pagination.', }) @ApiQuery({ name: 'userType', @@ -197,22 +197,40 @@ export class UserProfileController { type: Number, description: 'Minimum rating filter', }) + @ApiQuery({ + name: 'offset', + required: false, + type: Number, + description: 'Number of items to skip. Defaults to 0.', + }) + @ApiQuery({ + name: 'limit', + required: false, + type: Number, + description: 'Maximum number of items to return (max 100). Defaults to 20.', + }) @ApiResponse({ status: 200, - description: 'List of user profiles', + description: 'Paginated list of user profiles', schema: { - type: 'array', - items: { - type: 'object', - properties: { - id: { type: 'string' }, - walletAddress: { type: 'string' }, - name: { type: 'string' }, - userType: { type: 'string' }, - rating: { type: 'number' }, - completedJobs: { type: 'number' }, - isVerified: { type: 'boolean' }, + type: 'object', + properties: { + data: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string' }, + walletAddress: { type: 'string' }, + name: { type: 'string' }, + userType: { type: 'string' }, + rating: { type: 'number' }, + completedJobs: { type: 'number' }, + isVerified: { type: 'boolean' }, + }, + }, }, + total: { type: 'number' }, }, }, }) @@ -220,18 +238,24 @@ export class UserProfileController { @Query('userType') userType?: UserType, @Query('status') status?: UserStatus, @Query('minRating') minRating?: number, + @Query('offset') offset?: number, + @Query('limit') limit?: number, ) { + const safeOffset = Math.max(0, Number(offset) || 0); + const safeLimit = Math.min(Math.max(1, Number(limit) || 20), 100); return this.userProfileService.findAll({ userType, status, minRating: minRating ? parseFloat(minRating.toString()) : undefined, + offset: safeOffset, + limit: safeLimit, }); } @Get('search') @ApiOperation({ summary: 'Search user profiles', - description: 'Search profiles by name, bio, or skills.', + description: 'Search profiles by name, bio, or skills. Results are ranked by relevance.', }) @ApiQuery({ name: 'q', @@ -240,12 +264,30 @@ export class UserProfileController { description: 'Search query', example: 'blockchain developer', }) + @ApiQuery({ + name: 'offset', + required: false, + type: Number, + description: 'Number of items to skip. Defaults to 0.', + }) + @ApiQuery({ + name: 'limit', + required: false, + type: Number, + description: 'Maximum number of items to return (max 100). Defaults to 20.', + }) @ApiResponse({ status: 200, - description: 'Search results', + description: 'Paginated search results ranked by relevance', }) - async search(@Query('q') query: string) { - return this.userProfileService.search(query); + async search( + @Query('q') query: string, + @Query('offset') offset?: number, + @Query('limit') limit?: number, + ) { + const safeOffset = Math.max(0, Number(offset) || 0); + const safeLimit = Math.min(Math.max(1, Number(limit) || 20), 100); + return this.userProfileService.search(query, { offset: safeOffset, limit: safeLimit }); } @Get(':id') diff --git a/backend/src/user-profile/user-profile.service.spec.ts b/backend/src/user-profile/user-profile.service.spec.ts index d94cb69..cbfb380 100644 --- a/backend/src/user-profile/user-profile.service.spec.ts +++ b/backend/src/user-profile/user-profile.service.spec.ts @@ -122,26 +122,36 @@ describe('UserProfileService', () => { }); it('should return all profiles without filters', async () => { - const profiles = await service.findAll(); - expect(profiles).toHaveLength(3); + const result = await service.findAll(); + expect(result.data).toHaveLength(3); + expect(result.total).toBe(3); }); it('should filter by userType', async () => { - const profiles = await service.findAll({ userType: UserType.FREELANCER }); - expect(profiles.length).toBeGreaterThanOrEqual(1); + const result = await service.findAll({ userType: UserType.FREELANCER }); + expect(result.data.length).toBeGreaterThanOrEqual(1); expect( - profiles.every(p => p.userType === UserType.FREELANCER || p.userType === UserType.BOTH), + result.data.every(p => p.userType === UserType.FREELANCER || p.userType === UserType.BOTH), ).toBe(true); }); it('should filter by status', async () => { - const profiles = await service.findAll({ status: UserStatus.ACTIVE }); - expect(profiles).toHaveLength(3); + const result = await service.findAll({ status: UserStatus.ACTIVE }); + expect(result.data).toHaveLength(3); }); it('should filter by minRating', async () => { - const profiles = await service.findAll({ minRating: 0 }); - expect(profiles).toHaveLength(3); + const result = await service.findAll({ minRating: 0 }); + expect(result.data).toHaveLength(3); + }); + + it('should paginate results', async () => { + const page1 = await service.findAll({ offset: 0, limit: 2 }); + const page2 = await service.findAll({ offset: 2, limit: 2 }); + + expect(page1.data).toHaveLength(2); + expect(page2.data).toHaveLength(1); + expect(page1.total).toBe(3); }); }); @@ -307,26 +317,66 @@ describe('UserProfileService', () => { }); it('should search by name', async () => { - const results = await service.search('blockchain'); - expect(results).toHaveLength(1); - expect(results[0].name).toContain('Blockchain'); + const result = await service.search('blockchain'); + expect(result.data).toHaveLength(1); + expect(result.data[0].name).toContain('Blockchain'); }); it('should search by bio', async () => { - const results = await service.search('solidity'); - expect(results).toHaveLength(1); - expect(results[0].bio).toContain('Solidity'); + const result = await service.search('solidity'); + expect(result.data).toHaveLength(1); + expect(result.data[0].bio).toContain('Solidity'); }); it('should search by skills', async () => { - const results = await service.search('rust'); - expect(results).toHaveLength(1); - expect(results[0].skills).toContain('Rust'); + const result = await service.search('rust'); + expect(result.data).toHaveLength(1); + expect(result.data[0].skills).toContain('Rust'); }); it('should return empty array for no matches', async () => { - const results = await service.search('nonexistent'); - expect(results).toHaveLength(0); + const result = await service.search('nonexistent'); + expect(result.data).toHaveLength(0); + }); + + it('should rank exact name match above prefix match', async () => { + await service.create({ + walletAddress: 'GZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ', + name: 'Blockchain', + userType: UserType.FREELANCER, + }); + + const result = await service.search('blockchain'); + expect(result.data).toHaveLength(2); + expect(result.data[0].name).toBe('Blockchain'); + expect(result.data[1].name).toBe('Blockchain Developer'); + }); + + it('should rank prefix name match above contains match', async () => { + await service.create({ + walletAddress: 'GZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ', + name: 'Blockchain Engineer', + userType: UserType.FREELANCER, + }); + + const result = await service.search('blockchain'); + const names = result.data.map(p => p.name); + const hasPrefix = names.indexOf('Blockchain Engineer') >= 0; + const hasContains = names.indexOf('Blockchain Developer') >= 0; + expect(hasPrefix).toBe(true); + expect(hasContains).toBe(true); + }); + + it('should rank name match above bio match', async () => { + const result = await service.search('solidity'); + expect(result.data).toHaveLength(1); + expect(result.data[0].name).toBe('Blockchain Developer'); + }); + + it('should paginate search results', async () => { + const result = await service.search('developer', { offset: 0, limit: 1 }); + expect(result.data).toHaveLength(1); + expect(result.total).toBe(2); }); }); }); diff --git a/backend/src/user-profile/user-profile.service.ts b/backend/src/user-profile/user-profile.service.ts index 1f4ddba..0c32d9e 100644 --- a/backend/src/user-profile/user-profile.service.ts +++ b/backend/src/user-profile/user-profile.service.ts @@ -104,7 +104,9 @@ export class UserProfileService { userType?: UserType; status?: UserStatus; minRating?: number; - }): Promise { + offset?: number; + limit?: number; + }): Promise<{ data: UserProfile[]; total: number }> { let profiles = Array.from(this.profiles.values()); if (filters?.userType) { @@ -121,7 +123,11 @@ export class UserProfileService { profiles = profiles.filter(p => p.rating >= (filters.minRating ?? 0)); } - return profiles; + const total = profiles.length; + const offset = filters?.offset ?? 0; + const limit = filters?.limit ?? 20; + const data = profiles.slice(offset, offset + limit); + return { data, total }; } /** @@ -228,15 +234,42 @@ export class UserProfileService { } /** - * Search profiles by name or skills + * Search profiles by name, bio, or skills with relevance ranking. + * Results are ranked: exact name match > prefix name match > name contains > bio/skills match. */ - async search(query: string): Promise { + async search( + query: string, + options?: { offset?: number; limit?: number }, + ): Promise<{ data: UserProfile[]; total: number }> { const lowerQuery = query.toLowerCase(); - return Array.from(this.profiles.values()).filter( - profile => - profile.name.toLowerCase().includes(lowerQuery) || - profile.bio?.toLowerCase().includes(lowerQuery) || - profile.skills?.some(skill => skill.toLowerCase().includes(lowerQuery)), - ); + const matches = Array.from(this.profiles.values()) + .filter( + profile => + profile.name.toLowerCase().includes(lowerQuery) || + profile.bio?.toLowerCase().includes(lowerQuery) || + profile.skills?.some(skill => skill.toLowerCase().includes(lowerQuery)), + ) + .map(profile => { + const nameLower = profile.name.toLowerCase(); + let rank = 0; + if (nameLower === lowerQuery) { + rank = 4; + } else if (nameLower.startsWith(lowerQuery)) { + rank = 3; + } else if (nameLower.includes(lowerQuery)) { + rank = 2; + } else if (profile.bio?.toLowerCase().includes(lowerQuery)) { + rank = 1; + } + return { profile, rank }; + }) + .sort((a, b) => b.rank - a.rank) + .map(({ profile }) => profile); + + const total = matches.length; + const offset = options?.offset ?? 0; + const limit = options?.limit ?? 20; + const data = matches.slice(offset, offset + limit); + return { data, total }; } }