Repository navigation
feat(properties): listing OpenAPI specs, query sanitization, search refactor and read retry - #1476
Merged
nanaf6203-bit merged 1 commit intoOct 18, 2026
Conversation
…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
|
@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! 🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Documentation, input hygiene, structure and resilience for the public listing routes (
GET /propertiesandGET /properties/search). This complements #1472, which covered the property resource routes.#1387 — OpenAPI specs
The listing routes were undocumented:
GET /propertieshad 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 readSearchPropertiesDto. Added@ApiOperation/@ApiQuery/@ApiResponseto 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:
locationalone is an OR across address, city, state and zipCode.statusparameter — passingstatus=DRAFTstill returns ACTIVE listings.bedrooms/bathroomsvalues take precedence over themin/maxranges.radiusKmis an approximation as a bounding box, not a true radius.latandlngmust be supplied together, andradiusKmneeds either a centre or a bounding box — otherwise400.nulland is excluded whenever either bound is set.SearchPropertiesDtois consumed through@Query(), so it is documented with@ApiQueryon the handler rather than@ApiPropertyon 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.tstrims and strips control characters from the 10 string filters ofSearchPropertiesDtoand the bulk exportfilter, wired in as@Transformcallbacks (the globalValidationPipealready runstransform: 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 < bor2 < 3must not silently change meaning, andcontains/equalsare 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
listingSearchCacheKeymeans?city= Austinand?city=Austinshare a single cache entry instead of occupying two.A second fix in this issue:
SearchPropertiesDtohas always declaredPROPERTY_SORT_FIELDS, butsortBywas validated with@IsString()alone and then interpolated straight intoorderBy: { [sortBy]: sortOrder }.?sortBy=nonexistentreached Prisma with a column that does not exist and came back as a 500 instead of a400the caller could act on.sortByis 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
searchPropertieswas an 80-line method containing two opposite strategies, plus the row annotation and the response shaping:Named helpers for each concern:
searchRelations,annotateTrustScore,matchesTrustScoreRange,buildPage.The split matters because
searchByPagingpushesskip/takeinto the database whilesearchByTrustScorecannot — 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 ataketo the wrong branch.searchRelationsis the other quiet win: the owner/neighborhoodincludewas 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
retryListingQuerywraps 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 — includingPrismaClientKnownRequestError— 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-
updaterisks applying it twice.Relationship to the geocoding helper in #1472: the two share a backoff schedule but differ deliberately in both
isRetryableand timeout. A provider4xxand a database4xxare 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.mdNot run
Nothing was installed, built, linted or tested — please verify in CI.
Closes #1387
Closes #1388
Closes #1389
Closes #1390