Skip to content

feat(properties): listing OpenAPI specs, query sanitization, search refactor and read retry - #1476

Merged
nanaf6203-bit merged 1 commit into
MettaChain:mainfrom
bilkee:feature/listingapi-openapi-sanitization-refactor-retry
Oct 18, 2026
Merged

nanaf6203-bit merged 1 commit into
MettaChain:mainfrom
bilkee:feature/listingapi-openapi-sanitization-refactor-retry

Conversation

@bilkee

@bilkee bilkee commented Oct 18, 2026

Copy link
Copy Markdown
Contributor

Documentation, input hygiene, structure and resilience for the public listing routes (GET /properties and GET /properties/search). This complements #1472, which covered the property resource routes.

#1387 — OpenAPI specs

The listing routes were undocumented: GET /properties had no operation, no description and no response codes, and the 25 search filters appeared nowhere in the spec — the only way to discover them was to read SearchPropertiesDto. Added @ApiOperation/@ApiQuery/@ApiResponse to both, 34 query parameters in total across price, location, type/category, size/age, geo, quality, paging/sorting and dates.

The description now spells out the behaviours a caller cannot infer from the signature — each of which was a plausible "the API is wrong" bug report, and none of which was discoverable before:

  • Filters combine with AND; location alone is an OR across address, city, state and zipCode.
  • Results are always ACTIVE and non-deleted regardless of the status parameter — passing status=DRAFT still returns ACTIVE listings.
  • Exact bedrooms/bathrooms values take precedence over the min/max ranges.
  • radiusKm is an approximation as a bounding box, not a true radius.
  • lat and lng must be supplied together, and radiusKm needs either a centre or a bounding box — otherwise 400.
  • The trust-score bounds are applied in memory after fetching, because the score is derived from the neighborhood row rather than stored. A listing with no neighborhood scores null and is excluded whenever either bound is set.

SearchPropertiesDto is consumed through @Query(), so it is documented with @ApiQuery on the handler rather than @ApiProperty on the DTO — the correct modelling for query parameters, and it also keeps each parameter's description next to the cross-field rules that apply to it.

→ docs/api/listings-openapi.md

#1388 — Input sanitization

sanitize-listing-query.util.ts trims and strips control characters from the 10 string filters of SearchPropertiesDto and the bulk export filter, wired in as @Transform callbacks (the global ValidationPipe already runs transform: true, so no pipe config changed).

Deliberately weaker than the write-path sanitization in #1472 — queries are not stripped of HTML tags, because a query is a filter, not content: a search for a < b or 2 < 3 must not silently change meaning, and contains/equals are already bound as parameters by Prisma so a tag in a filter has nothing to execute against. Stripping markup from a filter would only make the result set less predictable. Tag stripping belongs where a value is stored and rendered.

One side effect worth having: normalising before the value reaches listingSearchCacheKey means ?city= Austin and ?city=Austin share a single cache entry instead of occupying two.

A second fix in this issue: SearchPropertiesDto has always declared PROPERTY_SORT_FIELDS, but sortBy was validated with @IsString() alone and then interpolated straight into orderBy: { [sortBy]: sortOrder }. ?sortBy=nonexistent reached Prisma with a column that does not exist and came back as a 500 instead of a 400 the caller could act on. sortBy is now @IsIn(PROPERTY_SORT_FIELDS) — which is what that constant was declared for — which also stops an arbitrary column being used as a sort key.

→ docs/api/listings-input-sanitization.md

#1389 — Refactor: search

searchProperties was an 80-line method containing two opposite strategies, plus the row annotation and the response shaping:

searchProperties(dto)
├── hasTrustScoreFilter(dto)
├── searchByTrustScore(...)   fetch all, filter in memory, page in memory
└── searchByPaging(...)       skip/take + COUNT in one transaction

Named helpers for each concern: searchRelations, annotateTrustScore, matchesTrustScoreRange, buildPage.

The split matters because searchByPaging pushes skip/take into the database while searchByTrustScore cannot — the score is derived from the neighborhood row rather than stored. That asymmetry was invisible in the middle of one method, and it is exactly the kind of thing that gets "optimised" by accident by adding a take to the wrong branch. searchRelations is the other quiet win: the owner/neighborhood include was duplicated verbatim in both branches, so a field added to one was easy to forget in the other.

Behaviour is unchanged — trust-score exclusion of null, ACTIVE-only results, transaction page/count, paging defaults.

→ docs/api/listings-refactor.md

#1390 — Retry with backoff

retryListingQuery wraps both search branches' database reads.

What is transient is the whole point: Prisma P1001/P1002/P1008/P1017, PrismaClientInitializationError, and socket errors (ECONNRESET, ECONNREFUSED, EPIPE, ETIMEDOUT, EAI_AGAIN, ENOTFOUND, pool timeouts). Everything else — including PrismaClientKnownRequestError — is rethrown immediately. A query the database rejected on its own terms fails identically on a retry, so retrying only adds latency to a request that was always going to fail.

Two attempts, 50 ms base, 400 ms ceiling. This sits in front of an interactive request: the goal is to ride out a blip, not to survive an outage. A client waiting more than ~500 ms would rather get an error and retry than hold a connection and a worker. The $transaction([...]) is retried as a unit, since page and count must agree.

Writes are never retried — a dropped connection mid-update risks applying it twice.

Relationship to the geocoding helper in #1472: the two share a backoff schedule but differ deliberately in both isRetryable and timeout. A provider 4xx and a database 4xx are both non-retryable, but a provider timeout and a database timeout warrant different patience. They are kept as separate modules so each can be tuned for its own dependency; consolidating once both land is a reasonable follow-up.

→ docs/api/listings-retry.md

Not run

Nothing was installed, built, linted or tested — please verify in CI.

Closes #1387
Closes #1388
Closes #1389
Closes #1390

…efactor, read retry

Documentation, input hygiene, structure and resilience for the public listing
routes (`GET /properties` and `GET /properties/search`), complementing MettaChain#1472,
which covered the property resource routes.

- MettaChain#1387: the listing routes were undocumented - GET /properties had no
  operation, description or response codes, and the 25 search filters appeared
  nowhere in the spec. Added @ApiOperation/@ApiQuery/@apiresponse to both, 34
  query parameters in total, and spelled out the behaviours a caller cannot
  infer from the signature: filters combine with AND (except `location`, which is
  an OR across address/city/state/zip), results are always ACTIVE and
  non-deleted regardless of the `status` parameter, exact bedroom/bathroom
  values beat the min/max ranges, radiusKm is a bounding-box approximation, and
  lat/lng must be supplied together. Each of those was a plausible "the API is
  wrong" bug report that was undiscoverable before.
  docs/api/listings-openapi.md
- MettaChain#1388: `sanitize-listing-query.util.ts` trims and strips control characters
  from the 10 string filters of SearchPropertiesDto and the bulk export filter,
  wired in as class-transformer @Transform callbacks (the global ValidationPipe
  already runs with transform: true). Deliberately weaker than the write-path
  sanitization: queries are not stripped of HTML tags, because `a < b` must not
  silently change meaning and the values are bound as parameters anyway.
  Normalising before the value reaches the cache-key hash also means `?city=
   Austin` and `?city=Austin` share one cache entry.
  `sortBy` was @IsString() only and went straight into `orderBy: { [sortBy]: ... }`,
  so an unknown column produced a Prisma 500 instead of a 400 - it is now
  @isin(PROPERTY_SORT_FIELDS), which is what that constant was declared for.
  docs/api/listings-input-sanitization.md
- MettaChain#1389: searchProperties was an 80-line method holding two opposite strategies
  (skip/take pushed into the database, versus fetch-all-and-page-in-memory for
  trust scores), plus the row annotation and the response shaping. Split into
  searchByTrustScore / searchByPaging with named helpers
  (hasTrustScoreFilter, searchRelations, annotateTrustScore,
  matchesTrustScoreRange, buildPage). The duplicated owner/neighborhood include
  is now defined once, so a field added to one branch cannot be forgotten in the
  other. Behaviour is unchanged. docs/api/listings-refactor.md
- MettaChain#1390: retryListingQuery wraps both search branches' database reads.
  isTransientListingReadError retries connection failures (Prisma P1001/P1002/
  P1008/P1017, initialization errors, socket errors) and nothing else - a query
  the database rejected on its own terms fails identically on a retry. Two
  attempts, 50ms base, 400ms ceiling: this is in front of an interactive
  request, so it rides out a blip rather than surviving an outage. Writes are
  never retried. docs/api/listings-retry.md

Nothing was installed, built, linted or tested; please verify in CI.

Closes MettaChain#1387
Closes MettaChain#1388
Closes MettaChain#1389
Closes MettaChain#1390
@drips-wave

drips-wave Bot commented Oct 18, 2026

Copy link
Copy Markdown

@bilkee Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@nanaf6203-bit
nanaf6203-bit merged commit 450975b into MettaChain:main Oct 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants