From 4bb23b40baf4a3a2e7af571876bb88e26a645e38 Mon Sep 17 00:00:00 2001 From: oladev2026-tech Date: Sun, 18 Oct 2026 00:17:59 +0100 Subject: [PATCH] feat: Investment API error handling, logging, metrics; harden listing search - Add investment error helpers, structured logger and Prometheus metrics - Restrict sortBy to PROPERTY_SORT_FIELDS and cap free-text filters at 100 chars Co-Authored-By: Claude Opus 5.5 --- docs/api/investment-api.md | 5 ++++ docs/api/listing-api-hardening.md | 6 ++++ src/investments/investment-errors.ts | 27 ++++++++++++++++++ src/investments/investment-logger.ts | 15 ++++++++++ src/investments/investment-metrics.ts | 31 +++++++++++++++++++++ src/properties/dto/search-properties.dto.ts | 15 ++++++++-- 6 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 docs/api/investment-api.md create mode 100644 docs/api/listing-api-hardening.md create mode 100644 src/investments/investment-errors.ts create mode 100644 src/investments/investment-logger.ts create mode 100644 src/investments/investment-metrics.ts diff --git a/docs/api/investment-api.md b/docs/api/investment-api.md new file mode 100644 index 00000000..577a2c83 --- /dev/null +++ b/docs/api/investment-api.md @@ -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. diff --git a/docs/api/listing-api-hardening.md b/docs/api/listing-api-hardening.md new file mode 100644 index 00000000..44ae74b6 --- /dev/null +++ b/docs/api/listing-api-hardening.md @@ -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. diff --git a/src/investments/investment-errors.ts b/src/investments/investment-errors.ts new file mode 100644 index 00000000..d553b902 --- /dev/null +++ b/src/investments/investment-errors.ts @@ -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'); +} diff --git a/src/investments/investment-logger.ts b/src/investments/investment-logger.ts new file mode 100644 index 00000000..9a433daf --- /dev/null +++ b/src/investments/investment-logger.ts @@ -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 = {}, + level: 'log' | 'warn' | 'error' = 'log', +): void { + logger[level](JSON.stringify({ event, ...context, timestamp: new Date().toISOString() })); +} diff --git a/src/investments/investment-metrics.ts b/src/investments/investment-metrics.ts new file mode 100644 index 00000000..f8dd20b7 --- /dev/null +++ b/src/investments/investment-metrics.ts @@ -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'; +} diff --git a/src/properties/dto/search-properties.dto.ts b/src/properties/dto/search-properties.dto.ts index 2bfd9d1a..6f59237a 100644 --- a/src/properties/dto/search-properties.dto.ts +++ b/src/properties/dto/search-properties.dto.ts @@ -1,4 +1,4 @@ -import { IsIn, IsInt, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator'; +import { IsIn, IsInt, IsNumber, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator'; import { Type } from 'class-transformer'; import { PROPERTY_STATUS_ENUM } from '../../common/common.types'; @@ -42,28 +42,34 @@ export class SearchPropertiesDto { // ----- Location ----- @IsOptional() @IsString() + @MaxLength(100) city?: string; @IsOptional() @IsString() + @MaxLength(100) state?: string; @IsOptional() @IsString() + @MaxLength(100) zipCode?: string; @IsOptional() @IsString() + @MaxLength(100) country?: string; /** Free-text search across address, city, state and zipCode (case-insensitive). */ @IsOptional() @IsString() + @MaxLength(100) location?: string; // ----- Property type ----- @IsOptional() @IsString() + @MaxLength(100) propertyType?: string; // ----- Bedrooms ----- @@ -162,8 +168,8 @@ export class SearchPropertiesDto { // ----- Sorting ----- @IsOptional() - @IsString() - sortBy?: string; + @IsIn(PROPERTY_SORT_FIELDS) + sortBy?: PropertySortField; @IsOptional() @IsIn(['asc', 'desc']) @@ -172,14 +178,17 @@ export class SearchPropertiesDto { // ----- Category / tag / amenity ----- @IsOptional() @IsString() + @MaxLength(100) category?: string; @IsOptional() @IsString() + @MaxLength(100) tag?: string; @IsOptional() @IsString() + @MaxLength(100) amenityType?: string; // ----- Geo (point + radius or bounding box) -----