Skip to content
Open
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
34 changes: 34 additions & 0 deletions docs/api/properties-input-sanitization.md
Original file line number Diff line number Diff line change
@@ -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.
32 changes: 32 additions & 0 deletions docs/api/properties-openapi.md
Original file line number Diff line number Diff line change
@@ -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`.
26 changes: 26 additions & 0 deletions docs/api/properties-refactor.md
Original file line number Diff line number Diff line change
@@ -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.
39 changes: 39 additions & 0 deletions docs/api/properties-retry.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions src/properties/dto/amenity.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
23 changes: 23 additions & 0 deletions src/properties/dto/bulk-operations.dto.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,52 @@
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 })
propertyIds!: string[];
}

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.',
})
@Transform(TrimListingQuery)
@IsOptional()
@IsString()
Expand Down
Loading