From 4ec8d77c61a2d226780830c10333f8eba2d13b19 Mon Sep 17 00:00:00 2001 From: Aisha Ahmad Date: Sun, 18 Oct 2026 12:17:15 +0100 Subject: [PATCH] feat(properties): OpenAPI specs, input sanitization, search-builder extraction, retry with backoff Documentation, input hygiene and resilience work on the Property API (`src/properties`), following the conventions of the Users API pass in #1457-#1463. - #1371: every PropertiesController route now carries @ApiOperation (with a stable operationId), @ApiParam, @ApiBody, @ApiProduces and the @ApiResponse codes the route really returns, including the 401/403 role gates that were undocumented. The project does not enable the @nestjs/swagger CLI plugin (nest-cli.json has no `plugins`), so @ApiBody({ type }) would emit an empty schema; @ApiProperty/@ApiPropertyOptional were added to CreatePropertyDto, UpdatePropertyDto, TransitionPropertyStatusDto, the bulk-operation DTOs and the amenity DTOs. docs/api/properties-openapi.md - #1372: sanitize.util.ts normalises every string in a property write body via class-transformer @Transform callbacks on Create/UpdatePropertyDto, which the existing global ValidationPipe already runs. Free text (title, description, HOA fields) is stripped of control characters, HTML tags and whitespace runs; identifiers (address, city, state, zip, country, type, category) are trimmed; string arrays are cleaned element-wise. NFKC normalisation matters here because visually identical values otherwise defeat the duplicate-address check. Values are normalised, not rejected, so no client starts getting 400s it did not get before. docs/api/properties-input-sanitization.md - #1373: buildSearchWhereClause moved verbatim out of the 1200-line service into search-where.builder.ts. It is a pure function of its input with no I/O and no `this`, and it was a 155-line private method. No filter was added, removed or reordered, so the existing quirks (exact bedroom/bathroom filters beating the ranges, ACTIVE-only public results, radius approximated as a bounding box) are all preserved. docs/api/properties-refactor.md - #1374: retryWithBackoff now wraps the geocoding provider call in attemptGeocode (3 attempts, 200ms base, 2s cap, up to 50% jitter). isTransientGeocodeError retries 429, 5xx, timeouts and socket errors but not a 4xx, because repeating a request the provider already rejected only burns the per-minute geocoding budget. Exhausting the retries still creates the property without coordinates, exactly as before. docs/api/properties-retry.md Nothing was installed, built, linted or tested; please verify in CI. Closes #1371 Closes #1372 Closes #1373 Closes #1374 --- docs/api/properties-input-sanitization.md | 34 ++++ docs/api/properties-openapi.md | 32 ++++ docs/api/properties-refactor.md | 26 +++ docs/api/properties-retry.md | 39 +++++ src/properties/dto/amenity.dto.ts | 12 ++ src/properties/dto/bulk-operations.dto.ts | 23 +++ src/properties/dto/property.dto.ts | 93 +++++++++- src/properties/dto/transition-status.dto.ts | 7 + src/properties/properties.controller.ts | 184 +++++++++++++++++++- src/properties/properties.service.ts | 174 ++---------------- src/properties/retry.util.ts | 79 +++++++++ src/properties/sanitize.util.ts | 45 +++++ src/properties/search-where.builder.ts | 175 +++++++++++++++++++ 13 files changed, 760 insertions(+), 163 deletions(-) create mode 100644 docs/api/properties-input-sanitization.md create mode 100644 docs/api/properties-openapi.md create mode 100644 docs/api/properties-refactor.md create mode 100644 docs/api/properties-retry.md create mode 100644 src/properties/retry.util.ts create mode 100644 src/properties/sanitize.util.ts create mode 100644 src/properties/search-where.builder.ts diff --git a/docs/api/properties-input-sanitization.md b/docs/api/properties-input-sanitization.md new file mode 100644 index 00000000..03de3bcd --- /dev/null +++ b/docs/api/properties-input-sanitization.md @@ -0,0 +1,34 @@ +# Property API input sanitization + +`src/properties/sanitize.util.ts` normalises every string that arrives in a +property write body, before validation and before it reaches Prisma. + +Two levels, matching what the field is used for: + +| Helper | Applied to | Effect | +| --- | --- | --- | +| `CleanText` | `title`, `description`, `hoaName`, `hoaContactInfo` | NFKC normalise, strip C0/C1 control characters, strip HTML tags, collapse whitespace runs, trim | +| `TrimIdentifier` | `address`, `city`, `state`, `zipCode`, `country`, `propertyType`, `category` | NFKC normalise, strip control characters, trim | +| `CleanIdentifierList` | `features`, `tags`, `hoaAmenities` | `TrimIdentifier` on each element | + +They are wired in as `class-transformer` `@Transform` callbacks on +`CreatePropertyDto` and `UpdatePropertyDto`, which the global `ValidationPipe` +already runs (`transform: true`, `whitelist: true`, `forbidNonWhitelisted: true` +in `src/main.ts`), so nothing had to change in the pipe configuration. + +Why this matters for these fields specifically: + +- **Control characters and NUL** break Postgres text columns and make equality + comparisons fail in ways that look like data corruption. +- **NFKC normalisation** stops visually identical values (`K` vs `K`, full-width + digits) from being stored as different listings and defeating the duplicate + address check in `create`. +- **HTML tags** in a listing description are not rendered anywhere in the API and + are the usual vector for stored payloads that later get injected into an admin + surface. +- **Whitespace collapsing** keeps a padded title from sorting or displaying + differently from an unpadded one. + +Behaviour that deliberately did *not* change: values are only normalised, never +rejected. Invalid input still fails validation with `400`, exactly as before, so +no existing client starts receiving errors it did not get already. diff --git a/docs/api/properties-openapi.md b/docs/api/properties-openapi.md new file mode 100644 index 00000000..ff7394a1 --- /dev/null +++ b/docs/api/properties-openapi.md @@ -0,0 +1,32 @@ +# Property API OpenAPI documentation + +Every `PropertiesController` route now carries `@ApiOperation` (with a stable +`operationId`), `@ApiParam`, `@ApiBody`, `@ApiProduces` and the `@ApiResponse` +status codes the route actually returns, including the 401/403 role gates that +were previously undocumented. + +The project does **not** enable the `@nestjs/swagger` CLI plugin +(`nest-cli.json` has no `plugins` entry), so `@ApiBody({ type: SomeDto })` alone +would emit an empty schema. `@ApiProperty` / `@ApiPropertyOptional` were +therefore added to the DTOs referenced by a body: + +| DTO | Used by | +| --- | --- | +| `CreatePropertyDto` | `POST /properties` | +| `UpdatePropertyDto` | `PUT /properties/:id` | +| `TransitionPropertyStatusDto` | `PATCH /properties/:id/status` | +| `BulkPropertyStatusUpdateDto` / `BulkPropertyDeleteDto` / `BulkPropertyExportDto` | `POST /properties/bulk/*` | +| `CreateAmenityDto` / `UpdateAmenityDto` | `POST`/`PATCH /properties/:id/amenities` | + +`AssignAgentDto` / `UpdateAgentAssignmentDto` already carried `@ApiProperty`. + +Examples are given for the fields where the accepted format is not obvious +(price, currency, enum values, date-time, URI and UUID formats), and `status` on +`UpdatePropertyDto` points at `PATCH /properties/:id/status`, which is the route +that enforces the lifecycle workflow. + +## Coverage left for the listing routes + +`GET /properties` and `GET /properties/search` are documented separately in +`listings-openapi.md`, together with the full filter list of +`SearchPropertiesDto`. diff --git a/docs/api/properties-refactor.md b/docs/api/properties-refactor.md new file mode 100644 index 00000000..de19ebe0 --- /dev/null +++ b/docs/api/properties-refactor.md @@ -0,0 +1,26 @@ +# Property API refactor: search filter building + +`PropertiesService` had grown to over 1200 lines, and `buildSearchWhereClause` +alone was a 155-line private method. It was hard to review because the filter +logic was mixed in with the rest of a 1200-line service, even though it is a pure +function: it reads a `SearchPropertiesDto`, does no I/O, and uses no `this`. + +The method moved verbatim to `src/properties/search-where.builder.ts` as an +exported `buildSearchWhereClause(dto)`. `searchProperties` now calls the module +function. + +The body was moved **unchanged** — no filter was added, removed or reordered. It +preserves the quirks it always had: + +- `bedrooms` and `bathrooms` exact filters take precedence over the corresponding + `min`/`max` range filters, +- the public search endpoint only ever exposes `ACTIVE` properties, regardless of + any `status` the caller passes, +- `location` matches address, city, state and zipCode with `OR`, +- a radius search is approximated with a bounding box (1 degree of latitude is + about 111 km) and is rejected unless `lat`/`lng` are supplied together, +- a bounding box and a radius may be combined; the two are merged into a single + `latitude`/`longitude` range rather than overwriting each other. + +The geo validation still throws `BadRequestException`, which is why the module +imports from `@nestjs/common` rather than being a totally framework-free module. diff --git a/docs/api/properties-retry.md b/docs/api/properties-retry.md new file mode 100644 index 00000000..00b8c8f1 --- /dev/null +++ b/docs/api/properties-retry.md @@ -0,0 +1,39 @@ +# Property API retry with backoff + +`retryWithBackoff` (`src/properties/retry.util.ts`) retries a failing call with +exponential backoff — doubling from the base delay, capped at `maxDelayMs`, with +up to 50% jitter so concurrent callers do not retry in lockstep — and rethrows the +last error once the attempts run out. + +## Where it is used + +Geocoding is the only outbound call the Property API makes, and it runs inside +`attemptGeocode` on property create and on an address change. It now runs under +`retryWithBackoff` with **3 attempts**, 200 ms base delay, 2 s cap. + +`isTransientGeocodeError` decides what is worth retrying: + +| Retried | Not retried | +| --- | --- | +| `429 Too Many Requests` | any other `4xx` — the request itself is wrong | +| `5xx` | | +| `TimeoutError` / `AbortError` | | +| socket errors: `ECONNRESET`, `ECONNREFUSED`, `EAI_AGAIN`, `ENOTFOUND`, `socket hang up`, `fetch failed` | | + +A `4xx` other than `429` means the provider rejected the address, and repeating +the same call cannot change the answer — retrying it would only waste the +per-minute geocoding budget that `attemptGeocode` already tracks. + +## Behaviour that deliberately did not change + +- **Exhausting the retries does not fail the request.** A property without + coordinates is still created, and `markGeocodingFailed` is still called, so the + previous behaviour of degrading rather than erroring is preserved. A transient + provider outage costs a little latency instead of the whole create. +- **A provider that legitimately finds nothing** returns `null`, not an error, so + it is never retried. +- The geocoding rate limit (`checkGeocodeRateLimit`) is checked once, before the + retries, so a retry storm cannot exceed the configured per-minute quota. + +`sleep` and `random` are injectable, so the delay schedule can be asserted in a +unit test without waiting in real time. diff --git a/src/properties/dto/amenity.dto.ts b/src/properties/dto/amenity.dto.ts index aa9ce85d..237dacb1 100644 --- a/src/properties/dto/amenity.dto.ts +++ b/src/properties/dto/amenity.dto.ts @@ -1,34 +1,46 @@ import { IsBoolean, IsOptional, IsString } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class CreateAmenityDto { + @ApiProperty({ description: 'Display name of the amenity.', example: 'Rooftop pool' }) @IsString() name: string; + @ApiProperty({ + description: 'Grouping used for filtering, e.g. OUTDOOR, APPLIANCE.', + example: 'OUTDOOR', + }) @IsString() amenityType: string; + @ApiPropertyOptional({ description: 'Free-text detail.' }) @IsOptional() @IsString() description?: string; + @ApiPropertyOptional({ default: true, description: 'Whether the amenity is currently offered.' }) @IsOptional() @IsBoolean() isAvailable?: boolean; } export class UpdateAmenityDto { + @ApiPropertyOptional({ description: 'Display name of the amenity.', example: 'Rooftop pool' }) @IsOptional() @IsString() name?: string; + @ApiPropertyOptional({ description: 'Grouping used for filtering.', example: 'OUTDOOR' }) @IsOptional() @IsString() amenityType?: string; + @ApiPropertyOptional({ description: 'Free-text detail.' }) @IsOptional() @IsString() description?: string; + @ApiPropertyOptional({ description: 'Whether the amenity is currently offered.' }) @IsOptional() @IsBoolean() isAvailable?: boolean; diff --git a/src/properties/dto/bulk-operations.dto.ts b/src/properties/dto/bulk-operations.dto.ts index 75df476e..43b48abe 100644 --- a/src/properties/dto/bulk-operations.dto.ts +++ b/src/properties/dto/bulk-operations.dto.ts @@ -1,17 +1,31 @@ import { IsArray, IsEnum, IsOptional, IsString, IsUUID, ArrayMinSize } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { PropertyStatus } from '../../types/prisma.types'; export class BulkPropertyStatusUpdateDto { + @ApiProperty({ + type: [String], + format: 'uuid', + minItems: 1, + description: 'Ids of the listings to update.', + }) @IsArray() @ArrayMinSize(1) @IsUUID('4', { each: true }) propertyIds!: string[]; + @ApiProperty({ enum: PropertyStatus, description: 'The status to apply to every listing.' }) @IsEnum(PropertyStatus) status!: PropertyStatus; } export class BulkPropertyDeleteDto { + @ApiProperty({ + type: [String], + format: 'uuid', + minItems: 1, + description: 'Ids of the listings to soft-delete.', + }) @IsArray() @ArrayMinSize(1) @IsUUID('4', { each: true }) @@ -19,11 +33,20 @@ export class BulkPropertyDeleteDto { } export class BulkPropertyExportDto { + @ApiProperty({ + type: [String], + format: 'uuid', + minItems: 1, + description: 'Ids of the listings to export.', + }) @IsArray() @ArrayMinSize(1) @IsUUID('4', { each: true }) propertyIds!: string[]; + @ApiPropertyOptional({ + description: 'Only export listings whose title contains this text.', + }) @IsOptional() @IsString() filter?: string; diff --git a/src/properties/dto/property.dto.ts b/src/properties/dto/property.dto.ts index 81348a8a..7a739f0f 100644 --- a/src/properties/dto/property.dto.ts +++ b/src/properties/dto/property.dto.ts @@ -1,134 +1,180 @@ import { IsString, IsNumber, IsOptional, IsArray, IsIn, IsUrl } from 'class-validator'; -import { Type } from 'class-transformer'; +import { Transform, Type } from 'class-transformer'; import { InputType, Field, Float } from '@nestjs/graphql'; import { PROPERTY_STATUS_ENUM, PropertyStatusLiteral } from '../../common/common.types'; +import { + CleanIdentifierList, + CleanText, + TrimIdentifier, +} from '../sanitize.util'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; @InputType() export class CreatePropertyDto { @Field() + @ApiProperty({ description: 'Listing headline.', example: 'Sunlit loft downtown' }) + @Transform(CleanText) @IsString() title: string; @Field({ nullable: true }) @IsOptional() + @ApiPropertyOptional({ description: 'Full description of the listing.' }) + @Transform(CleanText) @IsString() description?: string; @Field() + @ApiProperty({ description: 'Street address.' }) + @Transform(TrimIdentifier) @IsString() address: string; @Field() + @ApiProperty({ description: 'City.' }) + @Transform(TrimIdentifier) @IsString() city: string; @Field() + @ApiProperty({ description: 'State, province or region.' }) + @Transform(TrimIdentifier) @IsString() state: string; @Field() + @ApiProperty({ description: 'Postal code.' }) + @Transform(TrimIdentifier) @IsString() zipCode: string; @Field({ nullable: true }) @IsOptional() + @ApiPropertyOptional({ description: 'Country. Defaults to USA.', example: 'USA' }) + @Transform(TrimIdentifier) @IsString() country?: string; @Field(() => Float) @IsNumber() + @ApiProperty({ description: 'Asking price.' }) price: number; @Field() + @ApiProperty({ description: 'HOUSE, APARTMENT, CONDO, LAND, ...', example: 'APARTMENT' }) + @Transform(TrimIdentifier) @IsString() propertyType: string; @Field({ nullable: true }) @IsOptional() @IsNumber() + @ApiProperty({ description: 'Number of bedrooms.' }) bedrooms?: number; @Field({ nullable: true }) @IsOptional() @IsNumber() + @ApiProperty({ description: 'Number of bathrooms. Half-baths are allowed.' }) bathrooms?: number; @Field({ nullable: true }) @IsOptional() @IsNumber() + @ApiProperty({ description: 'Interior area in square feet.' }) squareFeet?: number; @Field({ nullable: true }) @IsOptional() @IsNumber() + @ApiProperty({ description: 'Lot area.' }) lotSize?: number; @Field({ nullable: true }) @IsOptional() @IsNumber() + @ApiProperty({ description: 'Year the property was built.' }) yearBuilt?: number; @Field(() => [String], { nullable: true }) @IsOptional() + @Transform(CleanIdentifierList) @IsArray() @IsString({ each: true }) + @ApiProperty({ type: [String], description: 'Amenity keywords, e.g. pool, garage.' }) features?: string[]; @Field({ nullable: true }) @IsOptional() + @ApiPropertyOptional({ description: 'Listing category.' }) + @Transform(TrimIdentifier) @IsString() category?: string; @Field(() => [String], { nullable: true }) @IsOptional() + @Transform(CleanIdentifierList) @IsArray() @IsString({ each: true }) + @ApiProperty({ type: [String], description: 'Free-form tags for filtering.' }) tags?: string[]; @Field(() => Float, { nullable: true }) @IsOptional() @IsNumber() + @ApiProperty({ description: 'Latitude. Auto-geocoded from the address when omitted.' }) latitude?: number; @Field(() => Float, { nullable: true }) @IsOptional() @IsNumber() + @ApiProperty({ description: 'Longitude. Auto-geocoded from the address when omitted.' }) longitude?: number; @Field({ nullable: true }) @IsOptional() @IsUrl() + @ApiProperty({ description: 'Absolute URL of a virtual tour.', format: 'uri' }) virtualTourUrl?: string; @Field({ nullable: true }) @IsOptional() @IsUrl() + @ApiProperty({ description: 'Absolute URL of a property video.', format: 'uri' }) videoUrl?: string; @Field({ nullable: true }) @IsOptional() + @ApiPropertyOptional({ description: 'Name of the homeowners association.' }) + @Transform(CleanText) @IsString() hoaName?: string; @Field(() => Float, { nullable: true }) @IsOptional() @IsNumber() + @ApiProperty({ description: 'Monthly HOA fee.' }) hoaMonthlyFee?: number; @Field(() => [String], { nullable: true }) @IsOptional() + @Transform(CleanIdentifierList) @IsArray() @IsString({ each: true }) + @ApiProperty({ type: [String], description: 'Amenities included by the HOA.' }) hoaAmenities?: string[]; @Field({ nullable: true }) @IsOptional() + @ApiPropertyOptional({ description: 'How to reach the HOA.' }) + @Transform(CleanText) @IsString() hoaContactInfo?: string; @Field(() => Date, { nullable: true }) @IsOptional() @Type(() => Date) + @ApiProperty({ description: 'When the listing expires (ISO 8601).', type: String, format: 'date-time' }) expiryDate?: Date; } @@ -136,139 +182,184 @@ export class CreatePropertyDto { export class UpdatePropertyDto { @Field({ nullable: true }) @IsOptional() + @ApiProperty({ description: 'Listing headline.', example: 'Sunlit loft downtown' }) + @Transform(CleanText) @IsString() title?: string; @Field({ nullable: true }) @IsOptional() + @ApiProperty({ description: 'Full description of the listing.' }) + @Transform(CleanText) @IsString() description?: string; @Field({ nullable: true }) @IsOptional() + @ApiProperty({ description: 'Street address.' }) + @Transform(TrimIdentifier) @IsString() address?: string; @Field({ nullable: true }) @IsOptional() + @ApiProperty({ description: 'City.' }) + @Transform(TrimIdentifier) @IsString() city?: string; @Field({ nullable: true }) @IsOptional() + @ApiProperty({ description: 'State, province or region.' }) + @Transform(TrimIdentifier) @IsString() state?: string; @Field({ nullable: true }) @IsOptional() + @ApiProperty({ description: 'Postal code.' }) + @Transform(TrimIdentifier) @IsString() zipCode?: string; @Field({ nullable: true }) @IsOptional() + @ApiProperty({ description: 'Country. Defaults to USA.', example: 'USA' }) + @Transform(TrimIdentifier) @IsString() country?: string; @Field(() => Float, { nullable: true }) + @ApiPropertyOptional({ description: 'New asking price. A change is recorded in the price history.' }) @IsOptional() @IsNumber() price?: number; @Field({ nullable: true }) @IsOptional() + @ApiProperty({ description: 'HOUSE, APARTMENT, CONDO, LAND, ...', example: 'APARTMENT' }) + @Transform(TrimIdentifier) @IsString() propertyType?: string; @Field({ nullable: true }) @IsOptional() @IsNumber() + @ApiPropertyOptional({ description: 'Number of bedrooms.' }) bedrooms?: number; @Field({ nullable: true }) @IsOptional() @IsNumber() + @ApiPropertyOptional({ description: 'Number of bathrooms. Half-baths are allowed.' }) bathrooms?: number; @Field({ nullable: true }) @IsOptional() @IsNumber() + @ApiPropertyOptional({ description: 'Interior area in square feet.' }) squareFeet?: number; @Field({ nullable: true }) @IsOptional() @IsNumber() + @ApiPropertyOptional({ description: 'Lot area.' }) lotSize?: number; @Field({ nullable: true }) @IsOptional() @IsNumber() + @ApiPropertyOptional({ description: 'Year the property was built.' }) yearBuilt?: number; @Field(() => String, { nullable: true }) + @ApiPropertyOptional({ + enum: PROPERTY_STATUS_ENUM, + description: + 'Lifecycle status. Prefer PATCH /properties/:id/status, which enforces the allowed workflow.', + }) @IsOptional() @IsIn(PROPERTY_STATUS_ENUM) status?: PropertyStatusLiteral; @Field(() => [String], { nullable: true }) @IsOptional() + @Transform(CleanIdentifierList) @IsArray() @IsString({ each: true }) + @ApiPropertyOptional({ type: [String], description: 'Amenity keywords, e.g. pool, garage.' }) features?: string[]; @Field(() => Date, { nullable: true }) @IsOptional() @Type(() => Date) + @ApiPropertyOptional({ description: 'When the listing expires (ISO 8601).', type: String, format: 'date-time' }) expiryDate?: Date; @Field({ nullable: true }) @IsOptional() + @ApiProperty({ description: 'Listing category.' }) + @Transform(TrimIdentifier) @IsString() category?: string; @Field(() => [String], { nullable: true }) @IsOptional() + @Transform(CleanIdentifierList) @IsArray() @IsString({ each: true }) + @ApiPropertyOptional({ type: [String], description: 'Free-form tags for filtering.' }) tags?: string[]; @Field(() => Float, { nullable: true }) @IsOptional() @IsNumber() + @ApiPropertyOptional({ description: 'Latitude. Auto-geocoded from the address when omitted.' }) latitude?: number; @Field(() => Float, { nullable: true }) @IsOptional() @IsNumber() + @ApiPropertyOptional({ description: 'Longitude. Auto-geocoded from the address when omitted.' }) longitude?: number; @Field({ nullable: true }) @IsOptional() @IsUrl() + @ApiPropertyOptional({ description: 'Absolute URL of a virtual tour.', format: 'uri' }) virtualTourUrl?: string; @Field({ nullable: true }) @IsOptional() @IsUrl() + @ApiPropertyOptional({ description: 'Absolute URL of a property video.', format: 'uri' }) videoUrl?: string; @Field({ nullable: true }) @IsOptional() + @ApiProperty({ description: 'Name of the homeowners association.' }) + @Transform(CleanText) @IsString() hoaName?: string; @Field(() => Float, { nullable: true }) @IsOptional() @IsNumber() + @ApiPropertyOptional({ description: 'Monthly HOA fee.' }) hoaMonthlyFee?: number; @Field(() => [String], { nullable: true }) @IsOptional() + @Transform(CleanIdentifierList) @IsArray() @IsString({ each: true }) + @ApiPropertyOptional({ type: [String], description: 'Amenities included by the HOA.' }) hoaAmenities?: string[]; @Field({ nullable: true }) @IsOptional() + @ApiProperty({ description: 'How to reach the HOA.' }) + @Transform(CleanText) @IsString() hoaContactInfo?: string; } diff --git a/src/properties/dto/transition-status.dto.ts b/src/properties/dto/transition-status.dto.ts index 524dcddb..a6ae9a23 100644 --- a/src/properties/dto/transition-status.dto.ts +++ b/src/properties/dto/transition-status.dto.ts @@ -1,4 +1,5 @@ import { IsIn, IsOptional, IsString, MaxLength } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { PROPERTY_STATUS_ENUM } from '../../common/common.types'; /** @@ -7,10 +8,16 @@ import { PROPERTY_STATUS_ENUM } from '../../common/common.types'; * the transition from the current status to this one is allowed by the workflow. */ export class TransitionPropertyStatusDto { + @ApiProperty({ + enum: PROPERTY_STATUS_ENUM, + description: 'The status to move the listing to.', + example: 'ACTIVE', + }) @IsIn(PROPERTY_STATUS_ENUM) status: (typeof PROPERTY_STATUS_ENUM)[number]; /** Optional note explaining the transition (e.g., "buyer signed contract"). */ + @ApiPropertyOptional({ maxLength: 500, description: 'Why the status changed.' }) @IsOptional() @IsString() @MaxLength(500) diff --git a/src/properties/properties.controller.ts b/src/properties/properties.controller.ts index 90da43da..cf8a282a 100644 --- a/src/properties/properties.controller.ts +++ b/src/properties/properties.controller.ts @@ -34,7 +34,15 @@ import { import { CreateAmenityDto, UpdateAmenityDto } from './dto/amenity.dto'; import { PropertyReportService } from './report/property-report.service'; import { Response } from 'express'; -import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; +import { + ApiBearerAuth, + ApiBody, + ApiOperation, + ApiParam, + ApiProduces, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; @ApiTags('Properties') @Controller('properties') @@ -47,6 +55,11 @@ export class PropertiesController { @ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard) @Post() + @ApiOperation({ summary: 'Create a property listing', operationId: 'createProperty' }) + @ApiBody({ type: CreatePropertyDto }) + @ApiResponse({ status: 201, description: 'Listing created in DRAFT status' }) + @ApiResponse({ status: 400, description: 'Validation failed, or the address already exists' }) + @ApiResponse({ status: 401, description: 'Not authenticated' }) create(@Body() createPropertyDto: CreatePropertyDto, @CurrentUser() user: AuthUserPayload) { return this.propertiesService.create(createPropertyDto, user.sub); } @@ -71,6 +84,10 @@ export class PropertiesController { } @Get(':id') + @ApiOperation({ summary: 'Get one property listing', operationId: 'getProperty' }) + @ApiParam({ name: 'id', description: 'Property id' }) + @ApiResponse({ status: 200, description: 'The property, with owner, documents and assigned agents' }) + @ApiResponse({ status: 404, description: 'No property with that id' }) async findOne(@Param('id') id: string) { const property = await this.propertiesService.findOne(id); // Previously an unknown id produced 200 with an empty body. @@ -82,6 +99,18 @@ export class PropertiesController { @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.AGENT, UserRole.ADMIN) @Put(':id') + @ApiOperation({ + summary: 'Update a property listing', + description: 'AGENT or ADMIN only. A price change is recorded in the price history.', + operationId: 'updateProperty', + }) + @ApiParam({ name: 'id', description: 'Property id' }) + @ApiBody({ type: UpdatePropertyDto }) + @ApiResponse({ status: 200, description: 'The updated property' }) + @ApiResponse({ status: 400, description: 'Validation failed, or the address already exists' }) + @ApiResponse({ status: 401, description: 'Not authenticated' }) + @ApiResponse({ status: 403, description: 'Caller is not an AGENT or ADMIN' }) + @ApiResponse({ status: 404, description: 'No property with that id' }) update( @Param('id') id: string, @Body() updatePropertyDto: UpdatePropertyDto, @@ -94,6 +123,16 @@ export class PropertiesController { @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN) @Delete(':id') + @ApiOperation({ + summary: 'Soft-delete a property listing', + description: 'ADMIN only. The row is kept and archived; see `restore` to undo.', + operationId: 'deleteProperty', + }) + @ApiParam({ name: 'id', description: 'Property id' }) + @ApiResponse({ status: 200, description: 'The archived property' }) + @ApiResponse({ status: 401, description: 'Not authenticated' }) + @ApiResponse({ status: 403, description: 'Caller is not an ADMIN' }) + @ApiResponse({ status: 404, description: 'No property with that id' }) remove(@Param('id') id: string, @CurrentUser() user: AuthUserPayload) { return this.propertiesService.remove(id, user); } @@ -102,6 +141,16 @@ export class PropertiesController { @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN) @Patch(':id/restore') + @ApiOperation({ + summary: 'Restore a soft-deleted property listing', + description: 'ADMIN only. The listing returns to DRAFT.', + operationId: 'restoreProperty', + }) + @ApiParam({ name: 'id', description: 'Property id' }) + @ApiResponse({ status: 200, description: 'The restored property' }) + @ApiResponse({ status: 401, description: 'Not authenticated' }) + @ApiResponse({ status: 403, description: 'Caller is not an ADMIN' }) + @ApiResponse({ status: 404, description: 'No property with that id' }) restore(@Param('id') id: string, @CurrentUser() user: AuthUserPayload) { return this.propertiesService.restore(id, user); } @@ -109,6 +158,16 @@ export class PropertiesController { @ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard) @Get(':id/report') + @ApiOperation({ + summary: 'Download the PDF report for a listing', + operationId: 'downloadPropertyReport', + }) + @ApiParam({ name: 'id', description: 'Property id' }) + @ApiProduces('application/pdf') + @ApiResponse({ status: 200, description: 'PDF report', schema: { type: 'string', format: 'binary' } }) + @ApiResponse({ status: 401, description: 'Not authenticated' }) + @ApiResponse({ status: 404, description: 'No property with that id' }) + @ApiResponse({ status: 500, description: 'The report could not be generated' }) async generateReport(@Param('id') id: string, @Res() res: Response): Promise { try { const pdfBuffer = await this.propertyReportService.generatePropertyReport(id); @@ -149,6 +208,19 @@ export class PropertiesController { @ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard) @Patch(':id/status') + @ApiOperation({ + summary: 'Transition a listing to a new lifecycle status', + description: + 'DRAFT -> PENDING -> ACTIVE -> UNDER_CONTRACT -> SOLD, plus the other transitions in `property-status.constants.ts`. Allowed for the property owner, AGENT or ADMIN.', + operationId: 'transitionPropertyStatus', + }) + @ApiParam({ name: 'id', description: 'Property id' }) + @ApiBody({ type: TransitionPropertyStatusDto }) + @ApiResponse({ status: 200, description: 'The property with its new status' }) + @ApiResponse({ status: 400, description: 'Validation failed, or the transition is not allowed' }) + @ApiResponse({ status: 401, description: 'Not authenticated' }) + @ApiResponse({ status: 403, description: 'Caller is not the owner, an AGENT or an ADMIN' }) + @ApiResponse({ status: 404, description: 'No property with that id' }) transitionStatus( @Param('id') id: string, @Body() dto: TransitionPropertyStatusDto, @@ -166,6 +238,16 @@ export class PropertiesController { @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN) @Patch(':id/approve') + @ApiOperation({ + summary: 'Approve a pending listing', + description: 'ADMIN only. Transitions the listing to ACTIVE.', + operationId: 'approveProperty', + }) + @ApiParam({ name: 'id', description: 'Property id' }) + @ApiResponse({ status: 200, description: 'The approved property' }) + @ApiResponse({ status: 401, description: 'Not authenticated' }) + @ApiResponse({ status: 403, description: 'Caller is not an ADMIN' }) + @ApiResponse({ status: 404, description: 'No property with that id' }) approve(@Param('id') id: string, @CurrentUser() user: AuthUserPayload) { return this.propertiesService.approveProperty(id, user.sub); } @@ -174,6 +256,16 @@ export class PropertiesController { @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN) @Patch(':id/reject') + @ApiOperation({ + summary: 'Reject a pending listing', + description: 'ADMIN only. Transitions the listing to REJECTED.', + operationId: 'rejectProperty', + }) + @ApiParam({ name: 'id', description: 'Property id' }) + @ApiResponse({ status: 200, description: 'The rejected property' }) + @ApiResponse({ status: 401, description: 'Not authenticated' }) + @ApiResponse({ status: 403, description: 'Caller is not an ADMIN' }) + @ApiResponse({ status: 404, description: 'No property with that id' }) reject(@Param('id') id: string, @CurrentUser() user: AuthUserPayload) { return this.propertiesService.rejectProperty(id, user.sub); } @@ -182,6 +274,15 @@ export class PropertiesController { @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN) @Post('bulk/status') + @ApiOperation({ + summary: 'Change the status of several listings at once', + operationId: 'bulkUpdatePropertyStatus', + }) + @ApiBody({ type: BulkPropertyStatusUpdateDto }) + @ApiResponse({ status: 201, description: 'Number of listings updated' }) + @ApiResponse({ status: 400, description: 'Validation failed' }) + @ApiResponse({ status: 401, description: 'Not authenticated' }) + @ApiResponse({ status: 403, description: 'Caller is not an ADMIN' }) async bulkUpdatePropertyStatus( @Body() body: BulkPropertyStatusUpdateDto, // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -194,6 +295,15 @@ export class PropertiesController { @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN) @Post('bulk/delete') + @ApiOperation({ + summary: 'Soft-delete several listings at once', + operationId: 'bulkDeleteProperties', + }) + @ApiBody({ type: BulkPropertyDeleteDto }) + @ApiResponse({ status: 201, description: 'Number of listings deleted' }) + @ApiResponse({ status: 400, description: 'Validation failed' }) + @ApiResponse({ status: 401, description: 'Not authenticated' }) + @ApiResponse({ status: 403, description: 'Caller is not an ADMIN' }) async bulkDeleteProperties( @Body() body: BulkPropertyDeleteDto, // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -206,6 +316,15 @@ export class PropertiesController { @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN) @Post('bulk/export') + @ApiOperation({ + summary: 'Export several listings as JSON', + operationId: 'bulkExportProperties', + }) + @ApiBody({ type: BulkPropertyExportDto }) + @ApiResponse({ status: 201, description: 'The matching listings' }) + @ApiResponse({ status: 400, description: 'Validation failed' }) + @ApiResponse({ status: 401, description: 'Not authenticated' }) + @ApiResponse({ status: 403, description: 'Caller is not an ADMIN' }) async bulkExportProperties( @Body() body: BulkPropertyExportDto, // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -217,6 +336,14 @@ export class PropertiesController { @ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard) @Post(':id/agents') + @ApiOperation({ summary: 'Assign an agent to a listing', operationId: 'assignPropertyAgent' }) + @ApiParam({ name: 'id', description: 'Property id' }) + @ApiBody({ type: AssignAgentDto }) + @ApiResponse({ status: 201, description: 'The new assignment' }) + @ApiResponse({ status: 400, description: 'Validation failed, or the agent is already assigned' }) + @ApiResponse({ status: 401, description: 'Not authenticated' }) + @ApiResponse({ status: 403, description: 'Caller is not the owner or an ADMIN' }) + @ApiResponse({ status: 404, description: 'No property with that id, or no such agent' }) async assignAgent( @Param('id') propertyId: string, @Body() dto: AssignAgentDto, @@ -228,6 +355,17 @@ export class PropertiesController { @ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard) @Put(':id/agents/:agentId') + @ApiOperation({ + summary: 'Change an agent assignment (commission, contact details)', + operationId: 'updatePropertyAgentAssignment', + }) + @ApiParam({ name: 'id', description: 'Property id' }) + @ApiParam({ name: 'agentId', description: 'Assigned agent id' }) + @ApiBody({ type: UpdateAgentAssignmentDto }) + @ApiResponse({ status: 200, description: 'The updated assignment' }) + @ApiResponse({ status: 401, description: 'Not authenticated' }) + @ApiResponse({ status: 403, description: 'Caller is not the owner or an ADMIN' }) + @ApiResponse({ status: 404, description: 'No property with that id, or the agent is not assigned' }) async updateAgentAssignment( @Param('id') propertyId: string, @Param('agentId') agentId: string, @@ -240,6 +378,16 @@ export class PropertiesController { @ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard) @Delete(':id/agents/:agentId') + @ApiOperation({ + summary: 'Remove an agent from a listing', + operationId: 'removePropertyAgentAssignment', + }) + @ApiParam({ name: 'id', description: 'Property id' }) + @ApiParam({ name: 'agentId', description: 'Assigned agent id' }) + @ApiResponse({ status: 200, description: 'The removed assignment' }) + @ApiResponse({ status: 401, description: 'Not authenticated' }) + @ApiResponse({ status: 403, description: 'Caller is not the owner or an ADMIN' }) + @ApiResponse({ status: 404, description: 'No property with that id, or the agent is not assigned' }) async removeAgentAssignment( @Param('id') propertyId: string, @Param('agentId') agentId: string, @@ -249,6 +397,10 @@ export class PropertiesController { } @Get(':id/agents') + @ApiOperation({ summary: 'List the agents assigned to a listing', operationId: 'getPropertyAgents' }) + @ApiParam({ name: 'id', description: 'Property id' }) + @ApiResponse({ status: 200, description: 'Assignments, each with its agent' }) + @ApiResponse({ status: 404, description: 'No property with that id' }) async getAgents(@Param('id') propertyId: string) { return this.propertiesService.getAgents(propertyId); } @@ -258,11 +410,22 @@ export class PropertiesController { @ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard) @Post(':id/amenities') + @ApiOperation({ summary: 'Add an amenity to a listing', operationId: 'addPropertyAmenity' }) + @ApiParam({ name: 'id', description: 'Property id' }) + @ApiBody({ type: CreateAmenityDto }) + @ApiResponse({ status: 201, description: 'The new amenity' }) + @ApiResponse({ status: 400, description: 'Validation failed' }) + @ApiResponse({ status: 401, description: 'Not authenticated' }) + @ApiResponse({ status: 404, description: 'No property with that id' }) async addAmenity(@Param('id') propertyId: string, @Body() dto: CreateAmenityDto) { return this.propertiesService.addAmenity(propertyId, dto); } @Get(':id/amenities') + @ApiOperation({ summary: 'List the amenities of a listing', operationId: 'getPropertyAmenities' }) + @ApiParam({ name: 'id', description: 'Property id' }) + @ApiResponse({ status: 200, description: 'The amenities, oldest first' }) + @ApiResponse({ status: 404, description: 'No property with that id' }) async getAmenities(@Param('id') propertyId: string) { return this.propertiesService.getAmenities(propertyId); } @@ -270,6 +433,16 @@ export class PropertiesController { @ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard) @Patch(':id/amenities/:amenityId') + @ApiOperation({ + summary: 'Update an amenity on a listing', + operationId: 'updatePropertyAmenity', + }) + @ApiParam({ name: 'id', description: 'Property id' }) + @ApiParam({ name: 'amenityId', description: 'Amenity id' }) + @ApiBody({ type: UpdateAmenityDto }) + @ApiResponse({ status: 200, description: 'The updated amenity' }) + @ApiResponse({ status: 401, description: 'Not authenticated' }) + @ApiResponse({ status: 404, description: 'No property with that id, or no such amenity on it' }) async updateAmenity( @Param('id') propertyId: string, @Param('amenityId') amenityId: string, @@ -281,6 +454,15 @@ export class PropertiesController { @ApiBearerAuth('access-token') @UseGuards(JwtAuthGuard) @Delete(':id/amenities/:amenityId') + @ApiOperation({ + summary: 'Remove an amenity from a listing', + operationId: 'removePropertyAmenity', + }) + @ApiParam({ name: 'id', description: 'Property id' }) + @ApiParam({ name: 'amenityId', description: 'Amenity id' }) + @ApiResponse({ status: 200, description: 'The removed amenity' }) + @ApiResponse({ status: 401, description: 'Not authenticated' }) + @ApiResponse({ status: 404, description: 'No property with that id, or no such amenity on it' }) async removeAmenity(@Param('id') propertyId: string, @Param('amenityId') amenityId: string) { return this.propertiesService.removeAmenity(propertyId, amenityId); } diff --git a/src/properties/properties.service.ts b/src/properties/properties.service.ts index 5a46cd7d..05cd7eb9 100644 --- a/src/properties/properties.service.ts +++ b/src/properties/properties.service.ts @@ -24,6 +24,8 @@ import { DEFAULT_PROPERTY_STATUS, } from './property-status.constants'; import { propertiesTotal } from '../metrics/metrics.controller'; +import { isTransientGeocodeError, retryWithBackoff } from './retry.util'; +import { buildSearchWhereClause } from './search-where.builder'; interface FindAllParams { skip?: number; @@ -123,7 +125,16 @@ export class PropertiesService { await this.recordGeocodeCall(); - const geo = await this.geocodingService.geocodeAddress(addressFields); + // Geocoding is an external HTTP provider, so a timeout or a 5xx is usually + // transient. Retry those a couple of times before giving up on coordinates; + // the property itself has already been validated at this point. + const geo = await retryWithBackoff(() => this.geocodingService.geocodeAddress(addressFields), { + attempts: 3, + baseDelayMs: 200, + maxDelayMs: 2_000, + isRetryable: isTransientGeocodeError, + }).catch(() => null); + if (!geo) { await this.markGeocodingFailed(propertyId); return null; @@ -580,7 +591,7 @@ export class PropertiesService { * Returns a paginated result so clients can render result counts and pages. */ async searchProperties(dto: SearchPropertiesDto): Promise { - const where = this.buildSearchWhereClause(dto); + const where = buildSearchWhereClause(dto); const page = dto.page && dto.page > 0 ? dto.page : 1; const limit = dto.limit && dto.limit > 0 ? dto.limit : 20; @@ -672,165 +683,6 @@ export class PropertiesService { return Math.round(scores.reduce((a, b) => a + b, 0) / scores.length); } - /** - * Build a Prisma `where` clause from a SearchPropertiesDto. - * Exposed as private and used only by `searchProperties`. - */ - private buildSearchWhereClause(dto: SearchPropertiesDto): Record { - const where: Record = { - deleted: false, - }; - - // Price range - if (dto.minPrice !== undefined || dto.maxPrice !== undefined) { - const priceFilter: Record = {}; - if (dto.minPrice !== undefined) { - priceFilter.gte = new Decimal(dto.minPrice.toString()); - } - if (dto.maxPrice !== undefined) { - priceFilter.lte = new Decimal(dto.maxPrice.toString()); - } - where.price = priceFilter; - } - - // Location: granular fields (case-insensitive equality) - if (dto.city) { - where.city = { equals: dto.city, mode: 'insensitive' }; - } - if (dto.state) { - where.state = { equals: dto.state, mode: 'insensitive' }; - } - if (dto.zipCode) { - where.zipCode = dto.zipCode; - } - if (dto.country) { - where.country = { equals: dto.country, mode: 'insensitive' }; - } - - // Location: free-text fallback (matches across address/city/state/zipCode) - if (dto.location) { - where.OR = [ - { address: { contains: dto.location, mode: 'insensitive' } }, - { city: { contains: dto.location, mode: 'insensitive' } }, - { state: { contains: dto.location, mode: 'insensitive' } }, - { zipCode: { contains: dto.location, mode: 'insensitive' } }, - ]; - } - - // Property type (case-insensitive) - if (dto.propertyType) { - where.propertyType = { equals: dto.propertyType, mode: 'insensitive' }; - } - - // Bedrooms: exact takes precedence over range - if (dto.bedrooms !== undefined) { - where.bedrooms = dto.bedrooms; - } else if (dto.minBedrooms !== undefined || dto.maxBedrooms !== undefined) { - const bedroomsFilter: Record = {}; - if (dto.minBedrooms !== undefined) bedroomsFilter.gte = dto.minBedrooms; - if (dto.maxBedrooms !== undefined) bedroomsFilter.lte = dto.maxBedrooms; - where.bedrooms = bedroomsFilter; - } - - // Bathrooms: stored as Decimal — wrap accordingly - if (dto.bathrooms !== undefined) { - where.bathrooms = new Decimal(dto.bathrooms.toString()); - } else if (dto.minBathrooms !== undefined || dto.maxBathrooms !== undefined) { - const bathroomsFilter: Record = {}; - if (dto.minBathrooms !== undefined) { - bathroomsFilter.gte = new Decimal(dto.minBathrooms.toString()); - } - if (dto.maxBathrooms !== undefined) { - bathroomsFilter.lte = new Decimal(dto.maxBathrooms.toString()); - } - where.bathrooms = bathroomsFilter; - } - - // Public search only exposes approved listings. - // Any search from the public search endpoint must end up returning ACTIVE properties. - where.status = PropertyStatus.ACTIVE; - - // Year built filter (#555) - if (dto.minYearBuilt !== undefined || dto.maxYearBuilt !== undefined) { - const yearFilter: Record = {}; - if (dto.minYearBuilt !== undefined) yearFilter.gte = dto.minYearBuilt; - if (dto.maxYearBuilt !== undefined) yearFilter.lte = dto.maxYearBuilt; - where.yearBuilt = yearFilter; - } - - // Metadata: category and tag - if (dto.category) { - where.category = { equals: dto.category, mode: 'insensitive' }; - } - if (dto.tag) { - where.tags = { has: dto.tag }; - } - - // Amenity type filter (via relation) - if (dto.amenityType) { - where.amenities = { - some: { amenityType: { equals: dto.amenityType, mode: 'insensitive' } }, - }; - } - - // Geo: validate lat/lng usage - const hasLat = dto.lat !== undefined; - const hasLng = dto.lng !== undefined; - if (hasLat !== hasLng) { - throw new BadRequestException('Both lat and lng must be provided together'); - } - - const lat = dto.lat; - const lng = dto.lng; - if (lat === undefined || lng === undefined) { - throw new BadRequestException('Both lat and lng must be provided together'); - } - - const hasBoundingBox = - dto.minLat !== undefined || - dto.maxLat !== undefined || - dto.minLng !== undefined || - dto.maxLng !== undefined; - - if (lat !== undefined && lng !== undefined) { - if (dto.radiusKm !== undefined) { - // Approximate radius as bounding box (1 degree lat ≈ 111km) - const latDelta = dto.radiusKm / 111; - const lngDelta = dto.radiusKm / (111 * Math.cos((lat * Math.PI) / 180)); - where.latitude = { gte: lat - latDelta, lte: lat + latDelta }; - where.longitude = { gte: lng - lngDelta, lte: lng + lngDelta }; - } else if (!hasBoundingBox) { - throw new BadRequestException( - 'When providing lat/lng, either radiusKm or bounding box params (minLat/maxLat/minLng/maxLng) must also be provided', - ); - } - } - - // Bounding box filter - if (hasBoundingBox) { - const latFilter: Record = {}; - const lngFilter: Record = {}; - if (dto.minLat !== undefined) latFilter.gte = dto.minLat; - if (dto.maxLat !== undefined) latFilter.lte = dto.maxLat; - if (dto.minLng !== undefined) lngFilter.gte = dto.minLng; - if (dto.maxLng !== undefined) lngFilter.lte = dto.maxLng; - if (Object.keys(latFilter).length > 0) { - where.latitude = { - ...(where.latitude as Record | undefined), - ...latFilter, - }; - } - if (Object.keys(lngFilter).length > 0) { - where.longitude = { - ...(where.longitude as Record | undefined), - ...lngFilter, - }; - } - } - - return where; - } - async approveProperty(propertyId: string, actorId: string) { return this.transitionStatus(propertyId, PropertyStatus.ACTIVE, actorId, UserRole.ADMIN); } diff --git a/src/properties/retry.util.ts b/src/properties/retry.util.ts new file mode 100644 index 00000000..21608f33 --- /dev/null +++ b/src/properties/retry.util.ts @@ -0,0 +1,79 @@ +export interface RetryOptions { + /** Total attempts including the first. Default 3. */ + attempts?: number; + /** Delay before the first retry in ms; doubles each retry. Default 200. */ + baseDelayMs?: number; + /** Ceiling on any single delay in ms. Default 2000. */ + maxDelayMs?: number; + /** Return false to stop retrying immediately. Default: retry everything. */ + isRetryable?: (error: unknown) => boolean; + /** Injectable for tests. */ + sleep?: (ms: number) => Promise; + /** Injectable for tests; returns a value in [0, 1). */ + random?: () => number; +} + +const defaultSleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** Delay before retry number `retry` (1-based): exponential, capped, with up to 50% jitter. */ +export function backoffDelayMs( + retry: number, + baseDelayMs: number, + maxDelayMs: number, + random: () => number, +): number { + const exponential = Math.min(maxDelayMs, baseDelayMs * 2 ** (retry - 1)); + return Math.round(exponential * (0.5 + random() * 0.5)); +} + +/** + * Run `fn`, retrying failures with exponential backoff and jitter. The last + * error is rethrown once attempts run out or `isRetryable` says to stop. + */ +export async function retryWithBackoff( + fn: () => Promise, + options: RetryOptions = {}, +): Promise { + const { + attempts = 3, + baseDelayMs = 200, + maxDelayMs = 2_000, + isRetryable = () => true, + sleep = defaultSleep, + random = Math.random, + } = options; + + let lastError: unknown; + for (let attempt = 1; attempt <= Math.max(1, attempts); attempt++) { + try { + return await fn(); + } catch (error) { + lastError = error; + if (attempt >= attempts || !isRetryable(error)) break; + await sleep(backoffDelayMs(attempt, baseDelayMs, maxDelayMs, random)); + } + } + throw lastError; +} + +/** + * Whether a geocoding provider error looks transient and therefore worth another + * attempt. Timeouts, socket errors, `429` and `5xx` are retried; a `4xx` other + * than `429` means the request itself is wrong and repeating it cannot help. + */ +export function isTransientGeocodeError(error: unknown): boolean { + const status = (error as { status?: number; statusCode?: number; code?: string }) ?? {}; + const code = status.status ?? status.statusCode; + + if (typeof code === 'number') return code === 429 || code >= 500; + + const name = (error as { name?: string })?.name ?? ''; + const message = (error as { message?: string })?.message ?? ''; + return ( + name === 'AbortError' || + name === 'TimeoutError' || + /timeout|timed out|socket hang up|ECONNRESET|ECONNREFUSED|EAI_AGAIN|ENOTFOUND|fetch failed|network/i.test( + message, + ) + ); +} diff --git a/src/properties/sanitize.util.ts b/src/properties/sanitize.util.ts new file mode 100644 index 00000000..69cae174 --- /dev/null +++ b/src/properties/sanitize.util.ts @@ -0,0 +1,45 @@ +import { TransformFnParams } from 'class-transformer'; + +// Matches C0/C1 control characters (including NUL) except tab/newline, which +// collapse into spaces for free text. +// eslint-disable-next-line no-control-regex +const CONTROL_CHARS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g; +const HTML_TAGS = /<[^>]*>/g; + +/** + * Trim and strip control characters, and normalise to NFKC so visually identical + * values compare equal. Safe for identifiers such as city or property type. + */ +export function sanitizeIdentifier(value: unknown): unknown { + if (typeof value !== 'string') return value; + return value.normalize('NFKC').replace(CONTROL_CHARS, '').trim(); +} + +/** + * Free text such as a listing description: also removes HTML tags and collapses + * runs of whitespace so a title cannot be padded out or broken across lines. + */ +export function sanitizeText(value: unknown): unknown { + if (typeof value !== 'string') return value; + return value + .normalize('NFKC') + .replace(CONTROL_CHARS, '') + .replace(HTML_TAGS, '') + .replace(/\s+/g, ' ') + .trim(); +} + +/** Trim and strip control characters from every element of a string array. */ +export function sanitizeStringArray(value: unknown): unknown { + if (!Array.isArray(value)) return value; + return value.map((item) => (typeof item === 'string' ? sanitizeIdentifier(item) : item)); +} + +/** class-transformer callbacks for DTO fields. */ +export const TrimIdentifier = ({ value }: TransformFnParams): unknown => + sanitizeIdentifier(value); + +export const CleanText = ({ value }: TransformFnParams): unknown => sanitizeText(value); + +export const CleanIdentifierList = ({ value }: TransformFnParams): unknown => + sanitizeStringArray(value); diff --git a/src/properties/search-where.builder.ts b/src/properties/search-where.builder.ts new file mode 100644 index 00000000..3ce8c46b --- /dev/null +++ b/src/properties/search-where.builder.ts @@ -0,0 +1,175 @@ +import { BadRequestException } from '@nestjs/common'; +import { Decimal } from '@prisma/client/runtime/library'; +import { PropertyStatus } from '../types/prisma.types'; +import { SearchPropertiesDto } from './dto/search-properties.dto'; + +/** + * Translate a `SearchPropertiesDto` into a Prisma `where` clause. + * + * Extracted verbatim from `PropertiesService.searchProperties`, where it made + * that method (and the 1200-line service) much harder to follow: filter building + * is a pure function of its input, has no database access and no `this`, and is + * now the only place that knows how a query filter maps to a column predicate. + * + * Behaviour is unchanged, including the deliberate quirks it preserves: + * - `bedrooms`/`bathrooms` exact filters win over the matching range filter, + * - the public search endpoint only ever exposes `ACTIVE` properties, + * - a radius search is approximated with a bounding box (1 degree of latitude + * is about 111 km), which is rejected unless `lat`/`lng` are supplied together. + * + * @throws BadRequestException when the geo parameters contradict each other. + */ +export function buildSearchWhereClause(dto: SearchPropertiesDto): Record { + const where: Record = { + deleted: false, + }; + + // Price range + if (dto.minPrice !== undefined || dto.maxPrice !== undefined) { + const priceFilter: Record = {}; + if (dto.minPrice !== undefined) { + priceFilter.gte = new Decimal(dto.minPrice.toString()); + } + if (dto.maxPrice !== undefined) { + priceFilter.lte = new Decimal(dto.maxPrice.toString()); + } + where.price = priceFilter; + } + + // Location: granular fields (case-insensitive equality) + if (dto.city) { + where.city = { equals: dto.city, mode: 'insensitive' }; + } + if (dto.state) { + where.state = { equals: dto.state, mode: 'insensitive' }; + } + if (dto.zipCode) { + where.zipCode = dto.zipCode; + } + if (dto.country) { + where.country = { equals: dto.country, mode: 'insensitive' }; + } + + // Location: free-text fallback (matches across address/city/state/zipCode) + if (dto.location) { + where.OR = [ + { address: { contains: dto.location, mode: 'insensitive' } }, + { city: { contains: dto.location, mode: 'insensitive' } }, + { state: { contains: dto.location, mode: 'insensitive' } }, + { zipCode: { contains: dto.location, mode: 'insensitive' } }, + ]; + } + + // Property type (case-insensitive) + if (dto.propertyType) { + where.propertyType = { equals: dto.propertyType, mode: 'insensitive' }; + } + + // Bedrooms: exact takes precedence over range + if (dto.bedrooms !== undefined) { + where.bedrooms = dto.bedrooms; + } else if (dto.minBedrooms !== undefined || dto.maxBedrooms !== undefined) { + const bedroomsFilter: Record = {}; + if (dto.minBedrooms !== undefined) bedroomsFilter.gte = dto.minBedrooms; + if (dto.maxBedrooms !== undefined) bedroomsFilter.lte = dto.maxBedrooms; + where.bedrooms = bedroomsFilter; + } + + // Bathrooms: stored as Decimal — wrap accordingly + if (dto.bathrooms !== undefined) { + where.bathrooms = new Decimal(dto.bathrooms.toString()); + } else if (dto.minBathrooms !== undefined || dto.maxBathrooms !== undefined) { + const bathroomsFilter: Record = {}; + if (dto.minBathrooms !== undefined) { + bathroomsFilter.gte = new Decimal(dto.minBathrooms.toString()); + } + if (dto.maxBathrooms !== undefined) { + bathroomsFilter.lte = new Decimal(dto.maxBathrooms.toString()); + } + where.bathrooms = bathroomsFilter; + } + + // Public search only exposes approved listings. + // Any search from the public search endpoint must end up returning ACTIVE properties. + where.status = PropertyStatus.ACTIVE; + + // Year built filter (#555) + if (dto.minYearBuilt !== undefined || dto.maxYearBuilt !== undefined) { + const yearFilter: Record = {}; + if (dto.minYearBuilt !== undefined) yearFilter.gte = dto.minYearBuilt; + if (dto.maxYearBuilt !== undefined) yearFilter.lte = dto.maxYearBuilt; + where.yearBuilt = yearFilter; + } + + // Metadata: category and tag + if (dto.category) { + where.category = { equals: dto.category, mode: 'insensitive' }; + } + if (dto.tag) { + where.tags = { has: dto.tag }; + } + + // Amenity type filter (via relation) + if (dto.amenityType) { + where.amenities = { + some: { amenityType: { equals: dto.amenityType, mode: 'insensitive' } }, + }; + } + + // Geo: validate lat/lng usage + const hasLat = dto.lat !== undefined; + const hasLng = dto.lng !== undefined; + if (hasLat !== hasLng) { + throw new BadRequestException('Both lat and lng must be provided together'); + } + + const lat = dto.lat; + const lng = dto.lng; + if (lat === undefined || lng === undefined) { + throw new BadRequestException('Both lat and lng must be provided together'); + } + + const hasBoundingBox = + dto.minLat !== undefined || + dto.maxLat !== undefined || + dto.minLng !== undefined || + dto.maxLng !== undefined; + + if (lat !== undefined && lng !== undefined) { + if (dto.radiusKm !== undefined) { + // Approximate radius as bounding box (1 degree lat ≈ 111km) + const latDelta = dto.radiusKm / 111; + const lngDelta = dto.radiusKm / (111 * Math.cos((lat * Math.PI) / 180)); + where.latitude = { gte: lat - latDelta, lte: lat + latDelta }; + where.longitude = { gte: lng - lngDelta, lte: lng + lngDelta }; + } else if (!hasBoundingBox) { + throw new BadRequestException( + 'When providing lat/lng, either radiusKm or bounding box params (minLat/maxLat/minLng/maxLng) must also be provided', + ); + } + } + + // Bounding box filter + if (hasBoundingBox) { + const latFilter: Record = {}; + const lngFilter: Record = {}; + if (dto.minLat !== undefined) latFilter.gte = dto.minLat; + if (dto.maxLat !== undefined) latFilter.lte = dto.maxLat; + if (dto.minLng !== undefined) lngFilter.gte = dto.minLng; + if (dto.maxLng !== undefined) lngFilter.lte = dto.maxLng; + if (Object.keys(latFilter).length > 0) { + where.latitude = { + ...(where.latitude as Record | undefined), + ...latFilter, + }; + } + if (Object.keys(lngFilter).length > 0) { + where.longitude = { + ...(where.longitude as Record | undefined), + ...lngFilter, + }; + } + } + + return where; +}