Skip to content
This repository was archived by the owner on May 12, 2026. It is now read-only.
191 changes: 191 additions & 0 deletions SEARCH_FEATURE.md
Original file line number Diff line number Diff line change
@@ -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
}
}
Comment thread
clinztouch marked this conversation as resolved.
```

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.
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -45,6 +46,7 @@ import { ThrottlerModule } from '@nestjs/throttler';
UsersModule,
AuthModule,
MailModule,
SearchModule,
],
providers: [
{
Expand Down
47 changes: 47 additions & 0 deletions src/database/migrations/1778520370661-AddProfileSearchFields.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AddProfileSearchFields1779000000000 implements MigrationInterface {
name = 'AddProfileSearchFields1779000000000';

public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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
`);
}
}
107 changes: 53 additions & 54 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -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();
12 changes: 12 additions & 0 deletions src/modules/search/dto/search-query.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading