Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/api/investment-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Investment API

- **Error handling** (`src/investments/investment-errors.ts`): `toInvestmentHttpError` passes HTTP exceptions through and maps unknown errors to a generic 500 after logging them.
- **Structured logging** (`src/investments/investment-logger.ts`): `logInvestmentEvent(event, context)` writes one JSON line per event under the `InvestmentAPI` logger.
- **Prometheus metrics** (`src/investments/investment-metrics.ts`): `investment_requests_total{operation,outcome}` and `investment_request_duration_seconds{operation}`, exposed via the existing `/metrics` endpoint.
6 changes: 6 additions & 0 deletions docs/api/listing-api-hardening.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Listing API hardening

`GET /properties/search` (`SearchPropertiesDto`):

- `sortBy` must be one of `PROPERTY_SORT_FIELDS`, so arbitrary column names cannot reach the query.
- Free-text filters (`city`, `state`, `zipCode`, `country`, `location`, `propertyType`, `category`, `tag`, `amenityType`) are capped at 100 characters.
27 changes: 27 additions & 0 deletions src/investments/investment-errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import {
BadRequestException,
HttpException,
InternalServerErrorException,
NotFoundException,
} from '@nestjs/common';
import { logInvestmentEvent } from './investment-logger';

export const investmentNotFound = (id: string) =>
new NotFoundException(`Investment with ID ${id} not found`);

export const invalidInvestmentAmount = () =>
new BadRequestException('Investment amount must be greater than zero');

/**
* Normalises unknown errors from the Investment API: HTTP exceptions pass through,
* anything else is logged and returned as a generic 500 so internals are not leaked.
*/
export function toInvestmentHttpError(error: unknown, operation: string): HttpException {
if (error instanceof HttpException) return error;
logInvestmentEvent(
'investment.unexpected_error',
{ operation, message: error instanceof Error ? error.message : String(error) },
'error',
);
return new InternalServerErrorException('Unexpected error while processing investment request');
}
15 changes: 15 additions & 0 deletions src/investments/investment-logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Logger } from '@nestjs/common';

const logger = new Logger('InvestmentAPI');

/**
* Structured log helper for the Investment API. Emits a single JSON line with a
* fixed `event` name plus context fields so logs are easy to query.
*/
export function logInvestmentEvent(
event: string,
context: Record<string, unknown> = {},
level: 'log' | 'warn' | 'error' = 'log',
): void {
logger[level](JSON.stringify({ event, ...context, timestamp: new Date().toISOString() }));
}
31 changes: 31 additions & 0 deletions src/investments/investment-metrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Counter, Histogram, register } from 'prom-client';

/**
* Prometheus metrics for the Investment API. Labels are bounded: `operation` is a
* handler name and `outcome` one of three values. Metrics are looked up before
* being created so hot reloads and test suites do not register them twice.
*/
export type InvestmentOutcome = 'success' | 'client_error' | 'server_error';

export const investmentRequestsTotal: Counter<'operation' | 'outcome'> =
(register.getSingleMetric('investment_requests_total') as Counter<'operation' | 'outcome'>) ??
new Counter({
name: 'investment_requests_total',
help: 'Investment API requests by handler and outcome',
labelNames: ['operation', 'outcome'],
});

export const investmentRequestDurationSeconds: Histogram<'operation'> =
(register.getSingleMetric('investment_request_duration_seconds') as Histogram<'operation'>) ??
new Histogram({
name: 'investment_request_duration_seconds',
help: 'Investment API handler latency in seconds',
labelNames: ['operation'],
buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5],
});

export function investmentOutcomeForStatus(status: number): InvestmentOutcome {
if (status >= 500) return 'server_error';
if (status >= 400) return 'client_error';
return 'success';
}
9 changes: 9 additions & 0 deletions src/properties/dto/search-properties.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,33 +44,39 @@ export class SearchPropertiesDto {
@IsOptional()
@Transform(TrimListingQuery)
@IsString()
@MaxLength(100)
city?: string;

@IsOptional()
@Transform(TrimListingQuery)
@IsString()
@MaxLength(100)
state?: string;

@IsOptional()
@Transform(TrimListingQuery)
@IsString()
@MaxLength(100)
zipCode?: string;

@IsOptional()
@Transform(TrimListingQuery)
@IsString()
@MaxLength(100)
country?: string;

/** Free-text search across address, city, state and zipCode (case-insensitive). */
@IsOptional()
@Transform(TrimListingQuery)
@IsString()
@MaxLength(100)
location?: string;

// ----- Property type -----
@IsOptional()
@Transform(TrimListingQuery)
@IsString()
@MaxLength(100)
propertyType?: string;

// ----- Bedrooms -----
Expand Down Expand Up @@ -184,16 +190,19 @@ export class SearchPropertiesDto {
@IsOptional()
@Transform(TrimListingQuery)
@IsString()
@MaxLength(100)
category?: string;

@IsOptional()
@Transform(TrimListingQuery)
@IsString()
@MaxLength(100)
tag?: string;

@IsOptional()
@Transform(TrimListingQuery)
@IsString()
@MaxLength(100)
amenityType?: string;

// ----- Geo (point + radius or bounding box) -----
Expand Down