From 61d7e99ec597192f9a97b3247cb3398c85cc2b4d Mon Sep 17 00:00:00 2001 From: {Calvin Iordye Date: Sun, 10 May 2026 13:28:55 +0100 Subject: [PATCH 01/10] feat: add user search and discovery endpoint - Add SearchQueryDto with validation (MinLength 2, MaxLength 100, type-safe trim) - Add search() method to UsersService with ILIKE, filters, sort, and pagination - Add GET /users/search to UsersController decorated with @Public() - Expose createQueryBuilder via UserModelAction - Add SEARCH_FEATURE.md documenting the feature - Response matches TransformInterceptor envelope (success, data, meta) --- SEARCH_FEATURE.md | 76 +++++++++++++++++++++++ src/modules/users/actions/user.action.ts | 8 ++- src/modules/users/dto/search-query.dto.ts | 57 +++++++++++++++++ src/modules/users/users.controller.ts | 10 +++ src/modules/users/users.service.ts | 54 ++++++++++++++++ 5 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 SEARCH_FEATURE.md create mode 100644 src/modules/users/dto/search-query.dto.ts diff --git a/SEARCH_FEATURE.md b/SEARCH_FEATURE.md new file mode 100644 index 0000000..1b3cccf --- /dev/null +++ b/SEARCH_FEATURE.md @@ -0,0 +1,76 @@ +# User Search & Discovery + +## Overview +Adds `GET /users/search` to the existing Users module, enabling case-insensitive discovery of users by name with filtering, sorting, and pagination. + +## Endpoint +GET /api/users/search + +Public endpoint — no authentication required. + +## Query Parameters + +| Parameter | Type | Required | Description | +|-----------|---------|----------|--------------------------------------| +| q | string | Yes | Search term (2–100 chars) | +| page | integer | No | Default: 1 | +| limit | integer | No | Default: 20, Max: 50 | +| verified | boolean | No | Filter to verified users only | +| role | string | No | Filter by role: `admin` or `user` | +| sort | string | No | `az`, `za`, `newest`, `oldest` | + +## Response + +```json +{ + "success": true, + "data": [ + { + "id": "uuid", + "fullName": "Calvin Iordye", + "role": null, + "isVerified": false, + "createdAt": "2026-05-10T11:59:24.644Z" + } + ], + "meta": { + "total": 1, + "page": 1, + "limit": 20, + "totalPages": 1 + } +} +``` + +## What Changed + +- `src/modules/users/dto/search-query.dto.ts` — new DTO with class-validator +- `src/modules/users/users.service.ts` — added `search()` method +- `src/modules/users/users.controller.ts` — added `GET /users/search` handler +- `src/modules/users/actions/user.action.ts` — exposed `createQueryBuilder()` + +## Design Decisions + +- Extends the existing Users module — no new module or infrastructure needed +- Uses PostgreSQL `ILIKE` for case-insensitive search on `fullName` +- `q` is trimmed before validation to prevent whitespace-only queries +- Sensitive fields (`email`, `password`, `otpHash`, `refreshTokenHash`) are never returned +- Soft-deleted users (`deletedAt IS NOT NULL`) are excluded from results +- Empty results return `data: []` — never a 404 + +## Error Responses + +| Status | Condition | +|--------|-----------| +| 400 | `q` missing or less than 2 characters | +| 422 | Invalid parameter format | +| 429 | Rate limit exceeded | + +## Testing +Tested manually via Swagger at `/docs`. All scenarios verified: +- Case-insensitive search +- Sorting (az, za, newest, oldest) +- Pagination +- Verified and role filters +- Empty results +- Sensitive field exclusion \ No newline at end of file diff --git a/src/modules/users/actions/user.action.ts b/src/modules/users/actions/user.action.ts index 663c8ba..4eb6595 100644 --- a/src/modules/users/actions/user.action.ts +++ b/src/modules/users/actions/user.action.ts @@ -13,7 +13,11 @@ export class UserModelAction extends AbstractModelAction { super(repo, User); } - async findByEmail(email: string): Promise { + findByEmail(email: string): Promise { return this.get({ identifierOptions: { email } }); } -} + + createQueryBuilder(alias: string) { // + return this.repository.createQueryBuilder(alias); + } +} \ No newline at end of file diff --git a/src/modules/users/dto/search-query.dto.ts b/src/modules/users/dto/search-query.dto.ts new file mode 100644 index 0000000..06874b4 --- /dev/null +++ b/src/modules/users/dto/search-query.dto.ts @@ -0,0 +1,57 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Transform, Type } from 'class-transformer'; +import { + IsBoolean, + IsEnum, + IsIn, + IsInt, + IsOptional, + IsString, + MaxLength, + MinLength, + Min, + Max, +} from 'class-validator'; +import { UserRole } from '../entities/user.entity'; + +export class SearchQueryDto { + @ApiProperty({ description: 'Search term (2–100 chars)', minLength: 2, maxLength: 100 }) + @Transform(({ value }) => + typeof value === 'string' ? value.trim() : value, + ) + @IsString() + @MinLength(2) + @MaxLength(100) + q: string; + + @ApiProperty({ required: false, default: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @ApiProperty({ required: false, default: 20 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(50) + limit?: number = 20; + + @ApiProperty({ required: false }) + @IsOptional() + @Transform(({ value }) => value === 'true' || value === true) + @IsBoolean() + verified?: boolean; + + @ApiProperty({ required: false, enum: UserRole }) + @IsOptional() + @IsEnum(UserRole) + role?: UserRole; + + @ApiProperty({ required: false, enum: ['az', 'za', 'newest', 'oldest'], default: 'az' }) + @IsOptional() + @IsIn(['az', 'za', 'newest', 'oldest']) + sort?: string = 'az'; +} \ No newline at end of file diff --git a/src/modules/users/users.controller.ts b/src/modules/users/users.controller.ts index 7c07805..87d257d 100644 --- a/src/modules/users/users.controller.ts +++ b/src/modules/users/users.controller.ts @@ -16,6 +16,8 @@ import { CreateUserDto } from './dto/create-user.dto'; import { PaginationDto } from './dto/pagination.dto'; import { UpdateUserDto } from './dto/update-user.dto'; import { UsersService } from './users.service'; +import { Public } from '../../common/decorators/public.decorator'; +import { SearchQueryDto } from './dto/search-query.dto'; @ApiTags('users') @ApiBearerAuth() @@ -35,6 +37,14 @@ export class UsersController { return this.usersService.findAll(pagination); } + // Public endpoint for searching users + @Get('search') + @Public() + @ApiOperation({ summary: 'Search users by name' }) + search(@Query() dto: SearchQueryDto) { + return this.usersService.search(dto); + } + @Get(':id') @ApiOperation({ summary: 'Get a user by id' }) findOne(@Param('id', ParseUUIDPipe) id: string) { diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts index dcad111..31324cd 100644 --- a/src/modules/users/users.service.ts +++ b/src/modules/users/users.service.ts @@ -14,6 +14,7 @@ import { PaginationDto } from './dto/pagination.dto'; import { UpdateUserDto } from './dto/update-user.dto'; import { AuthProvider, User } from './entities/user.entity'; import { ResetPassword } from '../auth/entities/reset-password.entity'; +import { SearchQueryDto } from './dto/search-query.dto'; const NO_TRANSACTION = { transactionOptions: { useTransaction: false as const }, @@ -264,4 +265,57 @@ export class UsersService { async invalidateAllByUserId(userId: string): Promise { await this.resetPasswordAction.invalidateAllByUserId(userId); } + // Implement the search method + async search(dto: SearchQueryDto) { + const { q, page = 1, limit = 20, verified, role, sort = 'az' } = dto; + + const qb = this.userModelAction + .createQueryBuilder('u') + .where('u.fullName ILIKE :q', { q: `%${q}%` }) + .andWhere('u.deletedAt IS NULL'); + + if (verified !== undefined) { + qb.andWhere('u.isVerified = :verified', { verified }); + } + + if (role) { + qb.andWhere('u.role = :role', { role }); + } + + switch (sort) { + case 'newest': + qb.orderBy('u.createdAt', 'DESC'); + break; + case 'oldest': + qb.orderBy('u.createdAt', 'ASC'); + break; + case 'za': + qb.orderBy('u.fullName', 'DESC'); + break; + default: + qb.orderBy('u.fullName', 'ASC'); + } + + const total = await qb.getCount(); + + qb.skip((page - 1) * limit).take(limit); + + const users = await qb.getMany(); + + return { + payload: users.map(({ id, fullName, role, isVerified, createdAt }) => ({ + id, + fullName, + role, + isVerified, + createdAt, + })), + paginationMeta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; +} } From 8b38de62ad0267d1fb7f45bcdf96f63acfa8bbd2 Mon Sep 17 00:00:00 2001 From: {Calvin Iordye Date: Sun, 10 May 2026 14:14:35 +0100 Subject: [PATCH 02/10] fix: restore users service methods after merge --- src/modules/users/users.service.ts | 117 ++++++++++++++++------------- 1 file changed, 63 insertions(+), 54 deletions(-) diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts index 31324cd..ce4987c 100644 --- a/src/modules/users/users.service.ts +++ b/src/modules/users/users.service.ts @@ -249,73 +249,82 @@ export class UsersService { }); } + logOAuthLogin(userId: string, ipAddress: string, provider: string): void { console.log( `OAuth login: userId=${userId} provider=${provider} ip=${ipAddress} time=${new Date().toISOString()}`, ); } + + async search(dto: SearchQueryDto) { + const { q, page = 1, limit = 20, verified, role, sort = 'az' } = dto; + + const qb = this.userModelAction + .createQueryBuilder('u') + .select([ + 'u.id', + 'u.fullName', + 'u.role', + 'u.isVerified', + 'u.createdAt', + ]) + .where('u.fullName ILIKE :q', { q: `%${q}%` }) + .andWhere('u.deletedAt IS NULL'); + + if (verified !== undefined) { + qb.andWhere('u.isVerified = :verified', { verified }); + } + + if (role) { + qb.andWhere('u.role = :role', { role }); + } + + switch (sort) { + case 'newest': + qb.orderBy('u.createdAt', 'DESC'); + break; + case 'oldest': + qb.orderBy('u.createdAt', 'ASC'); + break; + case 'za': + qb.orderBy('u.fullName', 'DESC'); + break; + case 'az': + default: + qb.orderBy('u.fullName', 'ASC'); + break; + } + + const total = await qb.getCount(); + qb.skip((page - 1) * limit).take(limit); + const users = await qb.getMany(); + + return { + payload: users.map(({ id, fullName, role, isVerified, createdAt }) => ({ + id, + fullName, + role, + isVerified, + createdAt, + })), + paginationMeta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } + async findLatestActiveByUserId( userId: string, ): Promise { return this.resetPasswordAction.findByUserId(userId); } - // Invalidates ALL active tokens for a user before issuing a new one async invalidateAllByUserId(userId: string): Promise { await this.resetPasswordAction.invalidateAllByUserId(userId); } - // Implement the search method - async search(dto: SearchQueryDto) { - const { q, page = 1, limit = 20, verified, role, sort = 'az' } = dto; - - const qb = this.userModelAction - .createQueryBuilder('u') - .where('u.fullName ILIKE :q', { q: `%${q}%` }) - .andWhere('u.deletedAt IS NULL'); - - if (verified !== undefined) { - qb.andWhere('u.isVerified = :verified', { verified }); - } - - if (role) { - qb.andWhere('u.role = :role', { role }); - } - - switch (sort) { - case 'newest': - qb.orderBy('u.createdAt', 'DESC'); - break; - case 'oldest': - qb.orderBy('u.createdAt', 'ASC'); - break; - case 'za': - qb.orderBy('u.fullName', 'DESC'); - break; - default: - qb.orderBy('u.fullName', 'ASC'); - } - - const total = await qb.getCount(); - - qb.skip((page - 1) * limit).take(limit); - - const users = await qb.getMany(); - - return { - payload: users.map(({ id, fullName, role, isVerified, createdAt }) => ({ - id, - fullName, - role, - isVerified, - createdAt, - })), - paginationMeta: { - total, - page, - limit, - totalPages: Math.ceil(total / limit), - }, - }; -} } + From e291f35108ab622039b1756c11c1c43561c046f5 Mon Sep 17 00:00:00 2001 From: {Calvin Iordye Date: Sun, 10 May 2026 14:29:33 +0100 Subject: [PATCH 03/10] fix: finalize users service after merge cleanup --- src/modules/users/users.service.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts index ce4987c..63f3072 100644 --- a/src/modules/users/users.service.ts +++ b/src/modules/users/users.service.ts @@ -210,14 +210,13 @@ export class UsersService { }); } - // Clears OTP fields without marking the account as verified — used for password reset flow. async clearOtpOnly(userId: string): Promise { - await this.userModelAction.update({ - ...NO_TRANSACTION, - identifierOptions: { id: userId }, - updatePayload: { otpHash: null, otpExpiresAt: null }, - }); - } + await this.userModelAction.update({ + ...NO_TRANSACTION, + identifierOptions: { id: userId }, + updatePayload: { otpHash: null, otpExpiresAt: null }, + }); +} async linkGoogleAccount(id: string): Promise { await this.userModelAction.update({ @@ -249,7 +248,6 @@ export class UsersService { }); } - logOAuthLogin(userId: string, ipAddress: string, provider: string): void { console.log( `OAuth login: userId=${userId} provider=${provider} ip=${ipAddress} time=${new Date().toISOString()}`, From b9a509f2ee061de4e27418956b9b903b62dd8810 Mon Sep 17 00:00:00 2001 From: {Calvin Iordye Date: Sun, 10 May 2026 14:39:08 +0100 Subject: [PATCH 04/10] fix: resolve lint issues in search dto --- src/modules/users/dto/search-query.dto.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/modules/users/dto/search-query.dto.ts b/src/modules/users/dto/search-query.dto.ts index 06874b4..a37f3a0 100644 --- a/src/modules/users/dto/search-query.dto.ts +++ b/src/modules/users/dto/search-query.dto.ts @@ -16,9 +16,10 @@ import { UserRole } from '../entities/user.entity'; export class SearchQueryDto { @ApiProperty({ description: 'Search term (2–100 chars)', minLength: 2, maxLength: 100 }) - @Transform(({ value }) => +@Transform( + ({ value }: { value: unknown }) => typeof value === 'string' ? value.trim() : value, - ) +) @IsString() @MinLength(2) @MaxLength(100) From a74c233471130bda0f8cd41dbba1a6df9a63cf24 Mon Sep 17 00:00:00 2001 From: {Calvin Iordye Date: Sun, 10 May 2026 23:19:37 +0100 Subject: [PATCH 05/10] fix: type SearchQueryDto trim transform to satisfy no-unsafe-return lint rule --- src/modules/users/dto/search-query.dto.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/modules/users/dto/search-query.dto.ts b/src/modules/users/dto/search-query.dto.ts index a37f3a0..1329a31 100644 --- a/src/modules/users/dto/search-query.dto.ts +++ b/src/modules/users/dto/search-query.dto.ts @@ -16,9 +16,8 @@ import { UserRole } from '../entities/user.entity'; export class SearchQueryDto { @ApiProperty({ description: 'Search term (2–100 chars)', minLength: 2, maxLength: 100 }) -@Transform( - ({ value }: { value: unknown }) => - typeof value === 'string' ? value.trim() : value, +@Transform(({ value }: { value: unknown }) => + typeof value === 'string' ? value.trim() : value, ) @IsString() @MinLength(2) From 3579feeca41b986c6f9306008671f4172b2c4bef Mon Sep 17 00:00:00 2001 From: {Calvin Iordye Date: Sun, 10 May 2026 23:24:10 +0100 Subject: [PATCH 06/10] fix: add security override to search Swagger docs and select only public columns in query --- src/modules/users/users.controller.ts | 12 ++++++------ src/modules/users/users.service.ts | 23 +++++++++++------------ 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/src/modules/users/users.controller.ts b/src/modules/users/users.controller.ts index 87d257d..61102bc 100644 --- a/src/modules/users/users.controller.ts +++ b/src/modules/users/users.controller.ts @@ -38,12 +38,12 @@ export class UsersController { } // Public endpoint for searching users - @Get('search') - @Public() - @ApiOperation({ summary: 'Search users by name' }) - search(@Query() dto: SearchQueryDto) { - return this.usersService.search(dto); - } +@Get('search') +@Public() +@ApiOperation({ summary: 'Search users by name', security: [] }) +search(@Query() dto: SearchQueryDto) { + return this.usersService.search(dto); +} @Get(':id') @ApiOperation({ summary: 'Get a user by id' }) diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts index 63f3072..681a5c6 100644 --- a/src/modules/users/users.service.ts +++ b/src/modules/users/users.service.ts @@ -258,18 +258,17 @@ export class UsersService { async search(dto: SearchQueryDto) { const { q, page = 1, limit = 20, verified, role, sort = 'az' } = dto; - const qb = this.userModelAction - .createQueryBuilder('u') - .select([ - 'u.id', - 'u.fullName', - 'u.role', - 'u.isVerified', - 'u.createdAt', - ]) - .where('u.fullName ILIKE :q', { q: `%${q}%` }) - .andWhere('u.deletedAt IS NULL'); - + const qb = this.userModelAction + .createQueryBuilder('u') + .select([ + 'u.id', + 'u.fullName', + 'u.role', + 'u.isVerified', + 'u.createdAt', + ]) + .where('u.fullName ILIKE :q', { q: `%${q}%` }) + .andWhere('u.deletedAt IS NULL'); if (verified !== undefined) { qb.andWhere('u.isVerified = :verified', { verified }); } From 046d5308e1bb87751e4ad0684b00b460b9e8a4d8 Mon Sep 17 00:00:00 2001 From: {Calvin Iordye Date: Mon, 11 May 2026 09:38:51 +0100 Subject: [PATCH 07/10] fix: improve verified transform and fix search method indentation --- src/modules/users/dto/search-query.dto.ts | 12 ++++++++---- src/modules/users/users.service.ts | 23 ++++++++++++----------- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/src/modules/users/dto/search-query.dto.ts b/src/modules/users/dto/search-query.dto.ts index 1329a31..127a6cc 100644 --- a/src/modules/users/dto/search-query.dto.ts +++ b/src/modules/users/dto/search-query.dto.ts @@ -16,9 +16,9 @@ import { UserRole } from '../entities/user.entity'; export class SearchQueryDto { @ApiProperty({ description: 'Search term (2–100 chars)', minLength: 2, maxLength: 100 }) -@Transform(({ value }: { value: unknown }) => - typeof value === 'string' ? value.trim() : value, -) + @Transform(({ value }: { value: unknown }) => + typeof value === 'string' ? value.trim() : value, + ) @IsString() @MinLength(2) @MaxLength(100) @@ -41,7 +41,11 @@ export class SearchQueryDto { @ApiProperty({ required: false }) @IsOptional() - @Transform(({ value }) => value === 'true' || value === true) + @Transform(({ value }: { value: unknown }) => { + if (value === 'true') return true; + if (value === 'false') return false; + return value; + }) @IsBoolean() verified?: boolean; diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts index 681a5c6..63f3072 100644 --- a/src/modules/users/users.service.ts +++ b/src/modules/users/users.service.ts @@ -258,17 +258,18 @@ export class UsersService { async search(dto: SearchQueryDto) { const { q, page = 1, limit = 20, verified, role, sort = 'az' } = dto; - const qb = this.userModelAction - .createQueryBuilder('u') - .select([ - 'u.id', - 'u.fullName', - 'u.role', - 'u.isVerified', - 'u.createdAt', - ]) - .where('u.fullName ILIKE :q', { q: `%${q}%` }) - .andWhere('u.deletedAt IS NULL'); + const qb = this.userModelAction + .createQueryBuilder('u') + .select([ + 'u.id', + 'u.fullName', + 'u.role', + 'u.isVerified', + 'u.createdAt', + ]) + .where('u.fullName ILIKE :q', { q: `%${q}%` }) + .andWhere('u.deletedAt IS NULL'); + if (verified !== undefined) { qb.andWhere('u.isVerified = :verified', { verified }); } From 58f87e34cd0f2946d91dfc8f14be189c3e90c863 Mon Sep 17 00:00:00 2001 From: {Calvin Iordye Date: Mon, 11 May 2026 10:09:22 +0100 Subject: [PATCH 08/10] fix: restore clearOtpOnly method and fix search method indentation --- src/modules/users/users.service.ts | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts index 63f3072..dee7cb2 100644 --- a/src/modules/users/users.service.ts +++ b/src/modules/users/users.service.ts @@ -211,12 +211,12 @@ export class UsersService { } async clearOtpOnly(userId: string): Promise { - await this.userModelAction.update({ - ...NO_TRANSACTION, - identifierOptions: { id: userId }, - updatePayload: { otpHash: null, otpExpiresAt: null }, - }); -} + await this.userModelAction.update({ + ...NO_TRANSACTION, + identifierOptions: { id: userId }, + updatePayload: { otpHash: null, otpExpiresAt: null }, + }); + } async linkGoogleAccount(id: string): Promise { await this.userModelAction.update({ @@ -254,7 +254,6 @@ export class UsersService { ); } - async search(dto: SearchQueryDto) { const { q, page = 1, limit = 20, verified, role, sort = 'az' } = dto; @@ -269,7 +268,6 @@ export class UsersService { ]) .where('u.fullName ILIKE :q', { q: `%${q}%` }) .andWhere('u.deletedAt IS NULL'); - if (verified !== undefined) { qb.andWhere('u.isVerified = :verified', { verified }); } @@ -325,4 +323,3 @@ export class UsersService { await this.resetPasswordAction.invalidateAllByUserId(userId); } } - From 08cc6452d6e7effb4037619947a8620a640a7fee Mon Sep 17 00:00:00 2001 From: {Calvin Iordye Date: Mon, 11 May 2026 10:17:06 +0100 Subject: [PATCH 09/10] fix: restore clearOtpOnly, fix search indentation and add dev branch methods --- src/modules/users/users.service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts index dee7cb2..5fa9f52 100644 --- a/src/modules/users/users.service.ts +++ b/src/modules/users/users.service.ts @@ -268,6 +268,7 @@ export class UsersService { ]) .where('u.fullName ILIKE :q', { q: `%${q}%` }) .andWhere('u.deletedAt IS NULL'); + if (verified !== undefined) { qb.andWhere('u.isVerified = :verified', { verified }); } From 21c422b5a32976309546f7919e53cde7c4436a1b Mon Sep 17 00:00:00 2001 From: {Calvin Iordye Date: Mon, 11 May 2026 19:52:22 +0100 Subject: [PATCH 10/10] feat(search): add public profile search --- SEARCH_FEATURE.md | 215 +++++++++++++---- src/app.module.ts | 2 + .../1778520370661-AddProfileSearchFields.ts | 47 ++++ src/main.ts | 107 +++++---- src/modules/search/dto/search-query.dto.ts | 12 + src/modules/search/search.controller.ts | 27 +++ src/modules/search/search.module.ts | 11 + src/modules/search/search.service.ts | 73 ++++++ src/modules/users/dto/search-query.dto.ts | 61 ----- src/modules/users/entities/user.entity.ts | 220 ++++++++++-------- src/modules/users/users.controller.ts | 122 +++++----- src/modules/users/users.service.ts | 61 ----- 12 files changed, 567 insertions(+), 391 deletions(-) create mode 100644 src/database/migrations/1778520370661-AddProfileSearchFields.ts create mode 100644 src/modules/search/dto/search-query.dto.ts create mode 100644 src/modules/search/search.controller.ts create mode 100644 src/modules/search/search.module.ts create mode 100644 src/modules/search/search.service.ts delete mode 100644 src/modules/users/dto/search-query.dto.ts diff --git a/SEARCH_FEATURE.md b/SEARCH_FEATURE.md index 1b3cccf..627a617 100644 --- a/SEARCH_FEATURE.md +++ b/SEARCH_FEATURE.md @@ -1,76 +1,191 @@ -# User Search & Discovery +# Profile Search ## Overview -Adds `GET /users/search` to the existing Users module, enabling case-insensitive discovery of users by name with filtering, sorting, and pagination. + +Adds the public landing-page search endpoint: + +```txt +GET /search?q={query} +``` + +The endpoint searches published profiles by full name and username using PostgreSQL `pg_trgm`, then returns the profile-card fields needed by the frontend. ## Endpoint -GET /api/users/search -Public endpoint — no authentication required. +```txt +GET /search +``` + +Public endpoint. No authentication is required. + +The app normally prefixes routes with `/api`, but `search` is excluded from the global prefix so the public route is exactly `/search`. ## Query Parameters -| Parameter | Type | Required | Description | -|-----------|---------|----------|--------------------------------------| -| q | string | Yes | Search term (2–100 chars) | -| page | integer | No | Default: 1 | -| limit | integer | No | Default: 20, Max: 50 | -| verified | boolean | No | Filter to verified users only | -| role | string | No | Filter by role: `admin` or `user` | -| sort | string | No | `az`, `za`, `newest`, `oldest` | +| Parameter | Type | Required | Description | +| --------- | ------ | -------- | ---------------------------------- | +| q | string | Yes | Search term. Minimum 2 characters. | ## Response +The service returns: + ```json { - "success": true, - "data": [ + "results": [ { - "id": "uuid", - "fullName": "Calvin Iordye", - "role": null, - "isVerified": false, - "createdAt": "2026-05-10T11:59:24.644Z" + "username": "adebayo", + "fullName": "Adebayo Johnson", + "bio": "Backend engineer and product builder.", + "photoUrl": "https://example.com/adebayo.jpg", + "isVerified": false } ], - "meta": { - "total": 1, - "page": 1, - "limit": 20, - "totalPages": 1 + "total": 1 +} +``` + +In the running app, the global response interceptor wraps this as: + +```json +{ + "success": true, + "data": { + "results": [ + { + "username": "adebayo", + "fullName": "Adebayo Johnson", + "bio": "Backend engineer and product builder.", + "photoUrl": "https://example.com/adebayo.jpg", + "isVerified": false + } + ], + "total": 1 } } ``` -## What Changed +If no profiles match, the endpoint returns HTTP `200` with: -- `src/modules/users/dto/search-query.dto.ts` — new DTO with class-validator -- `src/modules/users/users.service.ts` — added `search()` method -- `src/modules/users/users.controller.ts` — added `GET /users/search` handler -- `src/modules/users/actions/user.action.ts` — exposed `createQueryBuilder()` +```json +{ + "results": [], + "total": 0 +} +``` + +wrapped by the same global interceptor. + +## Search Logic + +- Searches `full_name` and `username`. +- Uses PostgreSQL `pg_trgm` with the trigram `%` operator. +- Orders exact username matches above partial matches. +- Orders remaining results by highest trigram similarity score. +- Includes only profiles where `is_published = true`. +- Excludes soft-deleted profiles where `deleted_at IS NOT NULL`. +- Limits responses to 20 results. +- Truncates `bio` to 120 characters. +- Uses parameterized query values to prevent SQL injection. + +## Schema Changes -## Design Decisions +Profile search fields were added to the existing `users` table: -- Extends the existing Users module — no new module or infrastructure needed -- Uses PostgreSQL `ILIKE` for case-insensitive search on `fullName` -- `q` is trimmed before validation to prevent whitespace-only queries -- Sensitive fields (`email`, `password`, `otpHash`, `refreshTokenHash`) are never returned -- Soft-deleted users (`deletedAt IS NOT NULL`) are excluded from results -- Empty results return `data: []` — never a 404 +- `username` +- `bio` +- `photo_url` +- `is_published` + +The migration also installs `pg_trgm`: + +```sql +CREATE EXTENSION IF NOT EXISTS pg_trgm; +``` + +and creates trigram indexes: + +```sql +CREATE INDEX IF NOT EXISTS users_full_name_trgm_idx +ON users USING GIN (full_name gin_trgm_ops); + +CREATE INDEX IF NOT EXISTS users_username_trgm_idx +ON users USING GIN (username gin_trgm_ops); +``` + +## Files Changed + +- `src/modules/search/search.module.ts` +- `src/modules/search/search.controller.ts` +- `src/modules/search/search.service.ts` +- `src/modules/search/dto/search-query.dto.ts` +- `src/modules/users/entities/user.entity.ts` +- `src/database/migrations/1778520370661-AddProfileSearchFields.ts` +- `src/app.module.ts` +- `src/main.ts` + +The old users search DTO was removed: + +- `src/modules/users/dto/search-query.dto.ts` ## Error Responses -| Status | Condition | -|--------|-----------| -| 400 | `q` missing or less than 2 characters | -| 422 | Invalid parameter format | -| 429 | Rate limit exceeded | - -## Testing -Tested manually via Swagger at `/docs`. All scenarios verified: -- Case-insensitive search -- Sorting (az, za, newest, oldest) -- Pagination -- Verified and role filters -- Empty results -- Sensitive field exclusion \ No newline at end of file +| Status | Condition | +| ------ | --------------------------------------------- | +| 400 | `q` is missing, blank, or under 2 characters. | +| 429 | Rate limit exceeded. | + +Short queries return: + +```json +{ + "success": false, + "statusCode": 400, + "error": "Bad Request", + "message": "Please enter at least 2 characters to search." +} +``` + +## Rate Limiting + +The search endpoint is limited to 60 requests per minute per IP: + +```ts +@Throttle({ default: { ttl: 60_000, limit: 60 } }) +``` + +Swagger/manual testing confirmed the response includes: + +```txt +x-ratelimit-limit: 60 +``` + +## Verification + +Manual Swagger tests: + +- `GET /search?q=ade` returns a published matching profile. +- `GET /search?q=q` returns HTTP `400` with `Please enter at least 2 characters to search.` +- Empty search results return HTTP `200` with `{ results: [], total: 0 }` inside the app response wrapper. + +Build verification: + +```bash +npm run format +npm run build +``` + +Local latency sample: + +```json +{ + "total": 50, + "concurrency": 5, + "statuses": { + "200": 50 + }, + "p95_ms": 96.02 +} +``` + +This local sample is under the 200ms p95 target. Production-like p95 should still be validated in staging with realistic data volume. diff --git a/src/app.module.ts b/src/app.module.ts index c534850..2e1656e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -22,6 +22,7 @@ import { UsersModule } from './modules/users/users.module'; import { QueueModule } from './modules/queue/queue.module'; import { MailModule } from './modules/mail/mail.module'; import { ThrottlerModule } from '@nestjs/throttler'; +import { SearchModule } from './modules/search/search.module'; @Module({ imports: [ @@ -45,6 +46,7 @@ import { ThrottlerModule } from '@nestjs/throttler'; UsersModule, AuthModule, MailModule, + SearchModule, ], providers: [ { diff --git a/src/database/migrations/1778520370661-AddProfileSearchFields.ts b/src/database/migrations/1778520370661-AddProfileSearchFields.ts new file mode 100644 index 0000000..8a0b842 --- /dev/null +++ b/src/database/migrations/1778520370661-AddProfileSearchFields.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddProfileSearchFields1779000000000 implements MigrationInterface { + name = 'AddProfileSearchFields1779000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS pg_trgm`); + + await queryRunner.query(` + ALTER TABLE users + ADD COLUMN IF NOT EXISTS username varchar(100), + ADD COLUMN IF NOT EXISTS bio text, + ADD COLUMN IF NOT EXISTS photo_url varchar(500), + ADD COLUMN IF NOT EXISTS is_published boolean NOT NULL DEFAULT false + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS users_username_unique_idx + ON users (username) + WHERE username IS NOT NULL + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS users_full_name_trgm_idx + ON users USING GIN (full_name gin_trgm_ops) + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS users_username_trgm_idx + ON users USING GIN (username gin_trgm_ops) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS users_username_trgm_idx`); + await queryRunner.query(`DROP INDEX IF EXISTS users_full_name_trgm_idx`); + await queryRunner.query(`DROP INDEX IF EXISTS users_username_unique_idx`); + + await queryRunner.query(` + ALTER TABLE users + DROP COLUMN IF EXISTS is_published, + DROP COLUMN IF EXISTS photo_url, + DROP COLUMN IF EXISTS bio, + DROP COLUMN IF EXISTS username + `); + } +} diff --git a/src/main.ts b/src/main.ts index 3a9e31d..56b0041 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,54 +1,53 @@ -import { Logger } from '@nestjs/common'; -import { NestFactory, Reflector } from '@nestjs/core'; -import { ClassSerializerInterceptor } from '@nestjs/common'; -import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; -import compression from 'compression'; -import cookieParser from 'cookie-parser'; -import helmet from 'helmet'; -import { AppModule } from './app.module'; -import { env } from './config/env'; - -async function bootstrap() { - const app = await NestFactory.create(AppModule, { - bufferLogs: true, - }); - - app.use(helmet()); - app.use(compression()); - app.use(cookieParser()); - app.enableCors({ - origin: - env.CORS_ORIGIN === '*' ? true : (env.CORS_ORIGIN).split(','), - credentials: true, - }); - app.setGlobalPrefix('api', { exclude: ['health'] }); - app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector))); - app.enableShutdownHooks(); - - if (env.SWAGGER_ENABLED) { - const config = new DocumentBuilder() - .setTitle('OpenProfile BE') - .setDescription('REST API documentation') - .setVersion('1.0.0') - .addServer(env.APP_URL) - .addBearerAuth( - { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, - 'JWT', - ) - .build(); - const document = SwaggerModule.createDocument(app, config); - SwaggerModule.setup('docs', app, document, { - swaggerOptions: { persistAuthorization: true }, - }); - } - - await app.listen(env.PORT); - - const logger = new Logger('Bootstrap'); - logger.log(`Application running on http://localhost:${env.PORT}`); - if (env.SWAGGER_ENABLED) { - logger.log(`Swagger docs at http://localhost:${env.PORT}/docs`); - } -} - -void bootstrap(); +import { Logger } from '@nestjs/common'; +import { NestFactory, Reflector } from '@nestjs/core'; +import { ClassSerializerInterceptor } from '@nestjs/common'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import compression from 'compression'; +import cookieParser from 'cookie-parser'; +import helmet from 'helmet'; +import { AppModule } from './app.module'; +import { env } from './config/env'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule, { + bufferLogs: true, + }); + + app.use(helmet()); + app.use(compression()); + app.use(cookieParser()); + app.enableCors({ + origin: env.CORS_ORIGIN === '*' ? true : env.CORS_ORIGIN.split(','), + credentials: true, + }); + app.setGlobalPrefix('api', { exclude: ['health', 'search'] }); + app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector))); + app.enableShutdownHooks(); + + if (env.SWAGGER_ENABLED) { + const config = new DocumentBuilder() + .setTitle('OpenProfile BE') + .setDescription('REST API documentation') + .setVersion('1.0.0') + .addServer(env.APP_URL) + .addBearerAuth( + { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, + 'JWT', + ) + .build(); + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('docs', app, document, { + swaggerOptions: { persistAuthorization: true }, + }); + } + + await app.listen(env.PORT); + + const logger = new Logger('Bootstrap'); + logger.log(`Application running on http://localhost:${env.PORT}`); + if (env.SWAGGER_ENABLED) { + logger.log(`Swagger docs at http://localhost:${env.PORT}/docs`); + } +} + +void bootstrap(); diff --git a/src/modules/search/dto/search-query.dto.ts b/src/modules/search/dto/search-query.dto.ts new file mode 100644 index 0000000..d9e4494 --- /dev/null +++ b/src/modules/search/dto/search-query.dto.ts @@ -0,0 +1,12 @@ +import { Transform } from 'class-transformer'; +import { IsOptional, IsString, MaxLength } from 'class-validator'; + +export class SearchQueryDto { + @IsOptional() + @IsString() + @MaxLength(100) + @Transform(({ value }: { value: unknown }) => + typeof value === 'string' ? value.trim() : value, + ) + q?: string; +} diff --git a/src/modules/search/search.controller.ts b/src/modules/search/search.controller.ts new file mode 100644 index 0000000..578a254 --- /dev/null +++ b/src/modules/search/search.controller.ts @@ -0,0 +1,27 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; +import { Throttle, ThrottlerGuard } from '@nestjs/throttler'; +import { Public } from '../../common/decorators/public.decorator'; +import { SearchQueryDto } from './dto/search-query.dto'; +import { SearchService } from './search.service'; + +@ApiTags('search') +@Controller('search') +export class SearchController { + constructor(private readonly searchService: SearchService) {} + + @Get() + @Public() + @UseGuards(ThrottlerGuard) + @Throttle({ default: { ttl: 60_000, limit: 60 } }) + @ApiOperation({ summary: 'Search published profiles' }) + @ApiQuery({ + name: 'q', + required: true, + description: 'Search term, minimum 2 characters', + example: 'ade', + }) + search(@Query() dto: SearchQueryDto) { + return this.searchService.searchProfiles(dto.q); + } +} diff --git a/src/modules/search/search.module.ts b/src/modules/search/search.module.ts new file mode 100644 index 0000000..2c95d4c --- /dev/null +++ b/src/modules/search/search.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { User } from '../users/entities/user.entity'; +import { SearchController } from './search.controller'; +import { SearchService } from './search.service'; +@Module({ + imports: [TypeOrmModule.forFeature([User])], + controllers: [SearchController], + providers: [SearchService], +}) +export class SearchModule {} diff --git a/src/modules/search/search.service.ts b/src/modules/search/search.service.ts new file mode 100644 index 0000000..5da8743 --- /dev/null +++ b/src/modules/search/search.service.ts @@ -0,0 +1,73 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { User } from '../users/entities/user.entity'; + +@Injectable() +export class SearchService { + constructor( + @InjectRepository(User) + private readonly userRepository: Repository, + ) {} + + async searchProfiles(q?: string) { + const sanitized = q + ?.trim() + .replace(/[^\w\s-]/g, '') + .replace(/\s+/g, ' '); + + if (!sanitized || sanitized.length < 2) { + throw new BadRequestException( + 'Please enter at least 2 characters to search.', + ); + } + type SearchProfileRow = { + username: string; + fullName: string; + bio: string | null; + photoUrl: string | null; + isVerified: boolean; +}; + + const results = await this.userRepository + .createQueryBuilder('u') + .where('u.is_published = true') + .andWhere('u.deleted_at IS NULL') + .andWhere('(u.full_name % :q OR u.username % :q)') + .select([ + 'u.username AS username', + 'u.full_name AS "fullName"', + 'u.bio AS bio', + 'u.photo_url AS "photoUrl"', + 'u.is_verified AS "isVerified"', + ]) + .orderBy( + 'CASE WHEN lower(u.username) = lower(:q) THEN 1 ELSE 0 END', + 'DESC', + ) + .addOrderBy( + `GREATEST( + similarity(u.full_name, :q), + similarity(u.username, :q) + )`, + 'DESC', + ) + .setParameters({ + q: sanitized, + }) + .limit(20) + .getRawMany(); + + + return { + results: results.map((user) => ({ + username: user.username, + fullName: user.fullName, + bio: user.bio?.slice(0, 120) ?? '', + photoUrl: user.photoUrl, + isVerified: user.isVerified, + })), + total: results.length, + }; + } +} diff --git a/src/modules/users/dto/search-query.dto.ts b/src/modules/users/dto/search-query.dto.ts deleted file mode 100644 index 127a6cc..0000000 --- a/src/modules/users/dto/search-query.dto.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { Transform, Type } from 'class-transformer'; -import { - IsBoolean, - IsEnum, - IsIn, - IsInt, - IsOptional, - IsString, - MaxLength, - MinLength, - Min, - Max, -} from 'class-validator'; -import { UserRole } from '../entities/user.entity'; - -export class SearchQueryDto { - @ApiProperty({ description: 'Search term (2–100 chars)', minLength: 2, maxLength: 100 }) - @Transform(({ value }: { value: unknown }) => - typeof value === 'string' ? value.trim() : value, - ) - @IsString() - @MinLength(2) - @MaxLength(100) - q: string; - - @ApiProperty({ required: false, default: 1 }) - @IsOptional() - @Type(() => Number) - @IsInt() - @Min(1) - page?: number = 1; - - @ApiProperty({ required: false, default: 20 }) - @IsOptional() - @Type(() => Number) - @IsInt() - @Min(1) - @Max(50) - limit?: number = 20; - - @ApiProperty({ required: false }) - @IsOptional() - @Transform(({ value }: { value: unknown }) => { - if (value === 'true') return true; - if (value === 'false') return false; - return value; - }) - @IsBoolean() - verified?: boolean; - - @ApiProperty({ required: false, enum: UserRole }) - @IsOptional() - @IsEnum(UserRole) - role?: UserRole; - - @ApiProperty({ required: false, enum: ['az', 'za', 'newest', 'oldest'], default: 'az' }) - @IsOptional() - @IsIn(['az', 'za', 'newest', 'oldest']) - sort?: string = 'az'; -} \ No newline at end of file diff --git a/src/modules/users/entities/user.entity.ts b/src/modules/users/entities/user.entity.ts index 0f08374..2477f89 100644 --- a/src/modules/users/entities/user.entity.ts +++ b/src/modules/users/entities/user.entity.ts @@ -1,99 +1,121 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { Exclude } from 'class-transformer'; -import { - Column, - CreateDateColumn, - DeleteDateColumn, - Entity, - Index, - PrimaryGeneratedColumn, - UpdateDateColumn, -} from 'typeorm'; - -export enum UserRole { - ADMIN = 'admin', - USER = 'user', -} - -export enum AuthProvider { - EMAIL = 'email', - GOOGLE = 'google', -} - -@Entity('users') -export class User { - @ApiProperty({ format: 'uuid' }) - @PrimaryGeneratedColumn('uuid') - id: string; - - @ApiProperty({ example: 'user@example.com' }) - @Index({ unique: true }) - @Column({ type: 'varchar', length: 255, unique: true }) - email: string; - - @Exclude() - @Column({ type: 'varchar', length: 255 }) - password: string; - - @ApiProperty() - @Column({ type: 'varchar', length: 255, name: 'full_name' }) - fullName: string; - - @ApiProperty({ enum: UserRole, nullable: true, default: null }) - @Column({ type: 'enum', enum: UserRole, nullable: true, default: null }) - role: UserRole | null; - - @ApiProperty({ enum: AuthProvider, default: AuthProvider.EMAIL }) - @Column({ - type: 'varchar', - length: 50, - name: 'auth_provider', - default: AuthProvider.EMAIL, - }) - authProvider: AuthProvider; - - @ApiProperty({ default: false }) - @Column({ type: 'boolean', name: 'is_verified', default: false }) - isVerified: boolean; - - @ApiProperty({ default: false }) - @Column({ type: 'boolean', name: 'onboarding_complete', default: false }) - onboardingComplete: boolean; - - @Exclude() - @Column({ type: 'varchar', length: 255, name: 'otp_hash', nullable: true }) - otpHash: string | null; - - @Exclude() - @Column({ - type: 'timestamp with time zone', - name: 'otp_expires_at', - nullable: true, - }) - otpExpiresAt: Date | null; - - @Exclude() - @Column({ type: 'varchar', length: 45, nullable: true, name: 'last_login_ip' }) - lastLoginIp: string | null; - - @Exclude() - @Column({ - type: 'varchar', - length: 500, - nullable: true, - name: 'refresh_token_hash', - }) - refreshTokenHash: string | null; - - @ApiProperty() - @CreateDateColumn({ name: 'created_at' }) - createdAt: Date; - - @ApiProperty() - @UpdateDateColumn({ name: 'updated_at' }) - updatedAt: Date; - - @Exclude() - @DeleteDateColumn({ name: 'deleted_at' }) - deletedAt: Date | null; -} +import { ApiProperty } from '@nestjs/swagger'; +import { Exclude } from 'class-transformer'; +import { + Column, + CreateDateColumn, + DeleteDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +export enum UserRole { + ADMIN = 'admin', + USER = 'user', +} + +export enum AuthProvider { + EMAIL = 'email', + GOOGLE = 'google', +} + +@Entity('users') +export class User { + @ApiProperty({ format: 'uuid' }) + @PrimaryGeneratedColumn('uuid') + id: string; + + @ApiProperty({ example: 'user@example.com' }) + @Index({ unique: true }) + @Column({ type: 'varchar', length: 255, unique: true }) + email: string; + + @Exclude() + @Column({ type: 'varchar', length: 255 }) + password: string; + + @ApiProperty() + @Column({ type: 'varchar', length: 255, name: 'full_name' }) + fullName: string; + + @ApiProperty({ nullable: true }) + @Index({ unique: true }) + @Column({ type: 'varchar', length: 100, nullable: true }) + username: string | null; + + @ApiProperty({ nullable: true }) + @Column({ type: 'text', nullable: true }) + bio: string | null; + + @ApiProperty({ nullable: true }) + @Column({ type: 'varchar', length: 500, name: 'photo_url', nullable: true }) + photoUrl: string | null; + + @ApiProperty({ default: false }) + @Column({ type: 'boolean', name: 'is_published', default: false }) + isPublished: boolean; + + @ApiProperty({ enum: UserRole, nullable: true, default: null }) + @Column({ type: 'enum', enum: UserRole, nullable: true, default: null }) + role: UserRole | null; + + @ApiProperty({ enum: AuthProvider, default: AuthProvider.EMAIL }) + @Column({ + type: 'varchar', + length: 50, + name: 'auth_provider', + default: AuthProvider.EMAIL, + }) + authProvider: AuthProvider; + + @ApiProperty({ default: false }) + @Column({ type: 'boolean', name: 'is_verified', default: false }) + isVerified: boolean; + + @ApiProperty({ default: false }) + @Column({ type: 'boolean', name: 'onboarding_complete', default: false }) + onboardingComplete: boolean; + + @Exclude() + @Column({ type: 'varchar', length: 255, name: 'otp_hash', nullable: true }) + otpHash: string | null; + + @Exclude() + @Column({ + type: 'timestamp with time zone', + name: 'otp_expires_at', + nullable: true, + }) + otpExpiresAt: Date | null; + + @Exclude() + @Column({ + type: 'varchar', + length: 45, + nullable: true, + name: 'last_login_ip', + }) + lastLoginIp: string | null; + + @Exclude() + @Column({ + type: 'varchar', + length: 500, + nullable: true, + name: 'refresh_token_hash', + }) + refreshTokenHash: string | null; + + @ApiProperty() + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @ApiProperty() + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + @Exclude() + @DeleteDateColumn({ name: 'deleted_at' }) + deletedAt: Date | null; +} diff --git a/src/modules/users/users.controller.ts b/src/modules/users/users.controller.ts index 61102bc..3542cde 100644 --- a/src/modules/users/users.controller.ts +++ b/src/modules/users/users.controller.ts @@ -1,66 +1,56 @@ -import { - Body, - Controller, - Delete, - Get, - HttpCode, - HttpStatus, - Param, - ParseUUIDPipe, - Patch, - Post, - Query, -} from '@nestjs/common'; -import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { CreateUserDto } from './dto/create-user.dto'; -import { PaginationDto } from './dto/pagination.dto'; -import { UpdateUserDto } from './dto/update-user.dto'; -import { UsersService } from './users.service'; -import { Public } from '../../common/decorators/public.decorator'; -import { SearchQueryDto } from './dto/search-query.dto'; - -@ApiTags('users') -@ApiBearerAuth() -@Controller('users') -export class UsersController { - constructor(private readonly usersService: UsersService) {} - - @Post() - @ApiOperation({ summary: 'Create a user' }) - create(@Body() dto: CreateUserDto) { - return this.usersService.create(dto); - } - - @Get() - @ApiOperation({ summary: 'List users (paginated)' }) - findAll(@Query() pagination: PaginationDto) { - return this.usersService.findAll(pagination); - } - - // Public endpoint for searching users -@Get('search') -@Public() -@ApiOperation({ summary: 'Search users by name', security: [] }) -search(@Query() dto: SearchQueryDto) { - return this.usersService.search(dto); -} - - @Get(':id') - @ApiOperation({ summary: 'Get a user by id' }) - findOne(@Param('id', ParseUUIDPipe) id: string) { - return this.usersService.findOne(id); - } - - @Patch(':id') - @ApiOperation({ summary: 'Update a user' }) - update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateUserDto) { - return this.usersService.update(id, dto); - } - - @Delete(':id') - @HttpCode(HttpStatus.NO_CONTENT) - @ApiOperation({ summary: 'Delete a user' }) - remove(@Param('id', ParseUUIDPipe) id: string) { - return this.usersService.remove(id); - } -} +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CreateUserDto } from './dto/create-user.dto'; +import { PaginationDto } from './dto/pagination.dto'; +import { UpdateUserDto } from './dto/update-user.dto'; +import { UsersService } from './users.service'; + +@ApiTags('users') +@ApiBearerAuth() +@Controller('users') +export class UsersController { + constructor(private readonly usersService: UsersService) {} + + @Post() + @ApiOperation({ summary: 'Create a user' }) + create(@Body() dto: CreateUserDto) { + return this.usersService.create(dto); + } + + @Get() + @ApiOperation({ summary: 'List users (paginated)' }) + findAll(@Query() pagination: PaginationDto) { + return this.usersService.findAll(pagination); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a user by id' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.usersService.findOne(id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update a user' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateUserDto) { + return this.usersService.update(id, dto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Delete a user' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.usersService.remove(id); + } +} diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts index 5fa9f52..56c1ae3 100644 --- a/src/modules/users/users.service.ts +++ b/src/modules/users/users.service.ts @@ -14,7 +14,6 @@ import { PaginationDto } from './dto/pagination.dto'; import { UpdateUserDto } from './dto/update-user.dto'; import { AuthProvider, User } from './entities/user.entity'; import { ResetPassword } from '../auth/entities/reset-password.entity'; -import { SearchQueryDto } from './dto/search-query.dto'; const NO_TRANSACTION = { transactionOptions: { useTransaction: false as const }, @@ -254,66 +253,6 @@ export class UsersService { ); } - async search(dto: SearchQueryDto) { - const { q, page = 1, limit = 20, verified, role, sort = 'az' } = dto; - - const qb = this.userModelAction - .createQueryBuilder('u') - .select([ - 'u.id', - 'u.fullName', - 'u.role', - 'u.isVerified', - 'u.createdAt', - ]) - .where('u.fullName ILIKE :q', { q: `%${q}%` }) - .andWhere('u.deletedAt IS NULL'); - - if (verified !== undefined) { - qb.andWhere('u.isVerified = :verified', { verified }); - } - - if (role) { - qb.andWhere('u.role = :role', { role }); - } - - switch (sort) { - case 'newest': - qb.orderBy('u.createdAt', 'DESC'); - break; - case 'oldest': - qb.orderBy('u.createdAt', 'ASC'); - break; - case 'za': - qb.orderBy('u.fullName', 'DESC'); - break; - case 'az': - default: - qb.orderBy('u.fullName', 'ASC'); - break; - } - - const total = await qb.getCount(); - qb.skip((page - 1) * limit).take(limit); - const users = await qb.getMany(); - - return { - payload: users.map(({ id, fullName, role, isVerified, createdAt }) => ({ - id, - fullName, - role, - isVerified, - createdAt, - })), - paginationMeta: { - total, - page, - limit, - totalPages: Math.ceil(total / limit), - }, - }; - } - async findLatestActiveByUserId( userId: string, ): Promise {