Skip to content
Merged
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
31 changes: 31 additions & 0 deletions docs/api/listings-error-handling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Listing API error handling

## Bulk status update silently published listings

`POST /properties/bulk/status` mapped the requested status with

```ts
status === DRAFT ? 'DRAFT' : status === ARCHIVED ? 'ARCHIVED' : 'ACTIVE'
```

Any status that was not `DRAFT` or `ARCHIVED` fell through to **`ACTIVE`**. Asking to bulk-mark listings `SOLD` or `PENDING` therefore published every one of them instead, and returned `200` with a plausible `updatedCount`. The response looked like a success and the data was wrong.

The endpoint now accepts only `DRAFT`, `ACTIVE` and `ARCHIVED`, and answers `400` naming the supported set for anything else. Nothing is written when the request is rejected.

## Bulk delete reported ids that were never deleted

`POST /properties/bulk/delete` returned `{ deletedCount, propertyIds }` where `propertyIds` was the **requested** list, echoed back unchanged. Deleting `['real-id', 'typo-id']` answered `{ deletedCount: 1, propertyIds: ['real-id', 'typo-id'] }`, which reads as if both had been removed.

The endpoint now resolves which ids actually exist first and returns those, so `deletedCount` and `propertyIds` always agree. When nothing matches it returns `{ deletedCount: 0, propertyIds: [] }` without touching the database. This costs one extra `findMany` with `select: { id: true }` on an admin-only endpoint.

## Foreign-key violations surfaced as opaque 500s

A listing referenced by a transaction, document or commission cannot be removed. That made Prisma throw `P2003`/`P2014`, which reached the client as an unhandled `500` carrying the raw driver message — internal schema detail in an error body.

It is now a `409 Conflict` with a message that says which situation occurred, and the underlying error is logged (`properties.bulk_delete_conflict`) rather than returned. Any other error still propagates unchanged.

## Empty id lists

Both bulk endpoints now reject an empty list with `400`. The DTOs already enforced `@ArrayMinSize(1)` at the HTTP boundary, but the service methods are also called directly, and `updateMany`/`deleteMany` with an empty `in` list is a silent no-op that reports `updatedCount: 0` — indistinguishable from "nothing matched".

→ `docs/api/listings-tests.md`
33 changes: 33 additions & 0 deletions docs/api/listings-structured-logging.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Property API structured logging

`PropertiesService` uses the project's `AppLogger`. Events are a short constant message plus a metadata object with a stable `event` name, so the JSON logs produced in production can be filtered and alerted on.

This document covers the listing surface; the lifecycle events added alongside the Property API pass are in `properties-structured-logging.md`.

| `event` | Level | Fields |
| --- | --- | --- |
| `properties.search` | log | `page`, `limit`, `total`, `returned`, `filters`, `trustScoreFiltered` |
| `properties.status_transitioned` | log | `propertyId`, `from`, `to`, `actorId` |
| `properties.status_transition_noop` | log | `propertyId`, `status`, `actorId` |
| `properties.approved` | log | `propertyId`, `actorId` |
| `properties.rejected` | log | `propertyId`, `actorId` |
| `properties.bulk_status_updated` | log | `requestedCount`, `updatedCount`, `status` |
| `properties.bulk_deleted` | warn | `requestedCount`, `deletedCount` |
| `properties.bulk_delete_no_match` | warn | `requestedCount` |
| `properties.bulk_delete_conflict` | warn | `requestedCount`, `error` |
| `properties.bulk_exported` | log | `requestedCount`, `matchedCount`, `filtered` |
| `properties.expired` | log | `updatedCount` |
| `properties.agent_assigned` | log | `propertyId`, `agentId`, `actorId` |
| `properties.agent_assignment_updated` | log | `propertyId`, `agentId`, `actorId` |
| `properties.agent_assignment_removed` | log | `propertyId`, `agentId`, `actorId` |
| `properties.amenity_added` | log | `propertyId`, `amenityId`, `amenityType` |
| `properties.amenity_updated` | log | `propertyId`, `amenityId` |
| `properties.amenity_removed` | log | `propertyId`, `amenityId` |

## Two details worth knowing

`properties.search` logs **how many** filters were supplied, never their values. A slow or empty search can therefore be attributed to a query shape — "every search with 8 filters is slow" — without putting addresses, cities or owner names into the log stream. Paging and sorting are excluded from the count, since `page` and `limit` are always present because the DTO defaults them.

Every bulk event logs both `requestedCount` and `updatedCount`. Before this, a bulk delete of five ids where only two existed reported a count of two and nothing else, so a partially-failed admin operation was indistinguishable from a complete one.

No addresses or personal data are logged; `AppLogger` also redacts fields such as `password`, `token` and `secret`.
25 changes: 25 additions & 0 deletions docs/api/listings-tests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Listing API tests

## Unit — `src/properties/properties.service.spec.ts`

Extends the existing suite (which covers create, lookups, remove and restore) over the listing surface that had none:

- **`searchProperties`** — paging arithmetic and the reported total, the defaults (`page 1`, `20` per page, newest first), that the query is forced to `ACTIVE` and non-deleted whatever `status` the caller passed, the computed `neighborhoodTrustScore` annotation, the in-memory trust-score branch (and that it does not use the transaction), and geo validation: `lat` without `lng` rejected, a radius with no centre rejected, and a radius converted to a bounding box.
- **`transitionStatus` / `approve` / `reject`** — `404` for an unknown listing, `403` for a caller who is neither owner, agent nor admin, `400` for a transition the workflow disallows, an idempotent no-op that does not write, the three roles that *are* allowed, and that approve goes to `ACTIVE` while reject goes to `ARCHIVED`.
- **Bulk operations** — each supported status, the rejection of an unsupported one, the empty-list guard, that `propertyIds` reports only ids that existed, that a no-match short-circuits, and that a `P2003` becomes a conflict rather than a 500.
- **Agent assignment** — `404`/`403`/role/duplicate guards, the 3% default commission, `404` on updating or removing an assignment that does not exist, and the contact-detail fallback in `getAgents`.
- **Amenities** — `404` for an unknown listing, refusal to touch an amenity that belongs to a different listing, the `isAvailable` default, and the oldest-first ordering.

The Prisma mock gained the `updateMany`, `deleteMany`, `count`, `$transaction`, `propertyAgent`, `propertyAmenity` and `user` delegates these suites drive.

## Integration — `test/e2e/properties-controller.integration.spec.ts`

Extends the existing HTTP suite (public reads, the 404 contract, search routing and query coercion, create validation, role enforcement) over the routes it did not touch:

- **Status lifecycle** — the route is open to any authenticated caller and forwards the caller id *and* role to the service, requires authentication, rejects a status outside the enum with `400`, surfaces a refused transition as `400`, and that `approve`/`reject` are admin-only.
- **Bulk operations** — admin-only, id-list validation (empty and malformed), the reported ids, a foreign-key conflict surfacing as `409`, and the optional export filter.
- **Agent assignment** — authentication, argument forwarding for create/update/remove, a missing assignment surfacing as `404`, and that the read route is unauthenticated.
- **Amenities** — body validation, create, the unauthenticated read, and both-ids forwarding on update and delete.
- **Report download** — that a PDF is streamed with its attachment headers, that a missing listing is `404`, and that an unexpected failure returns `500` **without** leaking the underlying error text into the body.

Run with `npx jest src/properties/properties.service.spec.ts test/e2e/properties-controller.integration.spec.ts`.
Loading