diff --git a/SEARCH_FEATURE.md b/SEARCH_FEATURE.md new file mode 100644 index 0000000..627a617 --- /dev/null +++ b/SEARCH_FEATURE.md @@ -0,0 +1,191 @@ +# Profile Search + +## Overview + +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 + +```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. Minimum 2 characters. | + +## Response + +The service returns: + +```json +{ + "results": [ + { + "username": "adebayo", + "fullName": "Adebayo Johnson", + "bio": "Backend engineer and product builder.", + "photoUrl": "https://example.com/adebayo.jpg", + "isVerified": false + } + ], + "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 + } +} +``` + +If no profiles match, the endpoint returns HTTP `200` with: + +```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 + +Profile search fields were added to the existing `users` table: + +- `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` 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/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/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 7c07805..3542cde 100644 --- a/src/modules/users/users.controller.ts +++ b/src/modules/users/users.controller.ts @@ -1,56 +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'; - -@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); - } -} +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 dcad111..56c1ae3 100644 --- a/src/modules/users/users.service.ts +++ b/src/modules/users/users.service.ts @@ -209,7 +209,6 @@ 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, @@ -260,7 +259,6 @@ export class UsersService { 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); }