From 6a47e7dfe43ae1e279cfe0203083accf7a003701 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 20 Aug 2026 14:00:29 +0000 Subject: [PATCH] docs(api): conventions for envelopes, pagination, and provenance (#321) Document staff REST asymmetries from integrator feedback: non-modifiable source on PATCH, response envelope keys, list total/hasMore, and that externalConfirmation already works on create/import. Link from README and integrations recipes; enrich Swagger examples on reservation routes. Co-authored-by: telivity-otaip --- README.md | 2 + .../reservation/dto/create-reservation.dto.ts | 8 ++- .../dto/import-reservations.dto.ts | 8 ++- .../reservation/dto/list-reservations.dto.ts | 6 +- .../reservation/reservation-ops.spec.ts | 1 + .../reservation/reservation.controller.ts | 69 +++++++++++++++--- .../reservation/reservation.service.ts | 4 +- docs/api-conventions.md | 72 +++++++++++++++++++ docs/integrations/README.md | 2 + 9 files changed, 160 insertions(+), 12 deletions(-) create mode 100644 docs/api-conventions.md diff --git a/README.md b/README.md index 5a135327..59ddb032 100644 --- a/README.md +++ b/README.md @@ -758,6 +758,8 @@ haip/ All endpoints are prefixed with `/api/v1/` and documented via OpenAPI 3.0. Run the API and visit `http://localhost:3000/docs` for the interactive Swagger UI. +Integrator shape notes (`propertyId` locations, cancel field aliases, response envelopes, pagination, `externalConfirmation`): **[`docs/api-conventions.md`](./docs/api-conventions.md)**. + ### Core Endpoints (~167 total)
diff --git a/apps/api/src/modules/reservation/dto/create-reservation.dto.ts b/apps/api/src/modules/reservation/dto/create-reservation.dto.ts index 9efb6568..227941fa 100644 --- a/apps/api/src/modules/reservation/dto/create-reservation.dto.ts +++ b/apps/api/src/modules/reservation/dto/create-reservation.dto.ts @@ -72,8 +72,14 @@ export class CreateReservationDto { @MaxLength(50) channelCode?: string; - @ApiPropertyOptional() + @ApiPropertyOptional({ + description: + 'External / source-system confirmation (OTA, GDS, migration id). Persisted on the booking; pair with channelCode for import idempotency.', + example: 'BDC-12345', + maxLength: 100, + }) @IsOptional() @IsString() + @MaxLength(100) externalConfirmation?: string; } diff --git a/apps/api/src/modules/reservation/dto/import-reservations.dto.ts b/apps/api/src/modules/reservation/dto/import-reservations.dto.ts index 09d088f9..4197cda0 100644 --- a/apps/api/src/modules/reservation/dto/import-reservations.dto.ts +++ b/apps/api/src/modules/reservation/dto/import-reservations.dto.ts @@ -100,9 +100,15 @@ export class CreateReservationRow { @MaxLength(50) channelCode?: string; - @ApiPropertyOptional() + @ApiPropertyOptional({ + description: + 'External / source-system confirmation. Persisted on the booking; with channelCode, used for idempotent dedupe on re-import.', + example: 'LEGACY-RES-42', + maxLength: 100, + }) @IsOptional() @IsString() + @MaxLength(100) externalConfirmation?: string; @ApiPropertyOptional({ diff --git a/apps/api/src/modules/reservation/dto/list-reservations.dto.ts b/apps/api/src/modules/reservation/dto/list-reservations.dto.ts index eccd6417..b40e646b 100644 --- a/apps/api/src/modules/reservation/dto/list-reservations.dto.ts +++ b/apps/api/src/modules/reservation/dto/list-reservations.dto.ts @@ -64,7 +64,11 @@ export class ListReservationsDto { @Min(1) page?: number = 1; - @ApiPropertyOptional({ default: 20 }) + @ApiPropertyOptional({ + default: 20, + description: + 'Page size (default 20, max 100). Response always includes total and hasMore so clients can detect truncation.', + }) @IsOptional() @Type(() => Number) @IsInt() diff --git a/apps/api/src/modules/reservation/reservation-ops.spec.ts b/apps/api/src/modules/reservation/reservation-ops.spec.ts index 729be65c..80e2d368 100644 --- a/apps/api/src/modules/reservation/reservation-ops.spec.ts +++ b/apps/api/src/modules/reservation/reservation-ops.spec.ts @@ -197,6 +197,7 @@ describe('ReservationService — list confirmationNumber', () => { const svc = await createService(createListDb([row])); const result = await svc.list({ propertyId: 'prop-001', limit: 20, page: 1 } as any); expect(result.total).toBe(1); + expect(result.hasMore).toBe(false); expect(result.data[0]).toMatchObject({ id: 'res-001', bookingId: 'book-001', diff --git a/apps/api/src/modules/reservation/reservation.controller.ts b/apps/api/src/modules/reservation/reservation.controller.ts index 17e4c6ac..fb33c003 100644 --- a/apps/api/src/modules/reservation/reservation.controller.ts +++ b/apps/api/src/modules/reservation/reservation.controller.ts @@ -134,24 +134,61 @@ export class ReservationController { // --- CRUD routes --- @Get() - @ApiOperation({ summary: 'List reservations with filters (propertyId required)' }) - @ApiResponse({ status: 200, description: 'Paginated list of reservations' }) + @ApiOperation({ + summary: 'List reservations with filters (propertyId required)', + description: + 'Paginated. Default limit=20. Response: { data, total, page, limit, hasMore }. Always read total/hasMore — a naive client that only looks at data silently gets page 1.', + }) + @ApiResponse({ + status: 200, + description: 'Paginated list of reservations', + schema: { + example: { + data: [{ id: '…', status: 'confirmed', arrivalDate: '2026-08-01' }], + total: 47, + page: 1, + limit: 20, + hasMore: true, + }, + }, + }) listReservations(@Query() dto: ListReservationsDto) { return this.reservationService.list(dto); } @Post() @Roles('admin', 'general_manager', 'front_desk', 'reservations') - @ApiOperation({ summary: 'Create new reservation (status: pending)' }) + @ApiOperation({ + summary: 'Create new reservation (status: pending)', + description: + 'Optional externalConfirmation stores the source-system reference on the booking (same field channel inbound uses).', + }) @ApiResponse({ status: 201, description: 'Reservation created' }) createReservation(@Body() dto: CreateReservationDto) { return this.reservationService.create(dto); } @Get(':id') - @ApiOperation({ summary: 'Get reservation with guest, room, and rate details' }) + @ApiOperation({ + summary: 'Get reservation with guest, room, and rate details', + description: + 'Envelope is NOT { data }. Returns { reservation, guest, roomType, ratePlan, room, confirmationNumber }.', + }) @ApiQuery({ name: 'propertyId', required: true }) - @ApiResponse({ status: 200, description: 'Reservation found' }) + @ApiResponse({ + status: 200, + description: 'Reservation found', + schema: { + example: { + reservation: { id: '…', status: 'confirmed' }, + guest: { id: '…', firstName: 'Ada' }, + roomType: { id: '…', name: 'King' }, + ratePlan: { id: '…', name: 'BAR' }, + room: null, + confirmationNumber: 'HAIP-…', + }, + }, + }) @ApiResponse({ status: 404, description: 'Reservation not found' }) getReservationById( @Param('id', ParseUUIDPipe) id: string, @@ -162,7 +199,11 @@ export class ReservationController { @Patch(':id') @Roles('admin', 'general_manager', 'front_desk', 'reservations') - @ApiOperation({ summary: 'Modify reservation (dates, room type, rate, occupancy)' }) + @ApiOperation({ + summary: 'Modify reservation (dates, room type, rate, occupancy)', + description: + 'Allowed: arrivalDate, departureDate, roomTypeId, ratePlanId, totalAmount, adults, children, specialRequests, doNotMove. Not patchable: source, channelCode, status, guestId (use dedicated lifecycle routes for status).', + }) @ApiQuery({ name: 'propertyId', required: true }) @ApiResponse({ status: 200, description: 'Reservation modified' }) @ApiResponse({ status: 404, description: 'Reservation not found' }) @@ -391,9 +432,21 @@ export class ReservationController { } @Get(':id/notes') - @ApiOperation({ summary: 'List notes for a reservation (with active count)' }) + @ApiOperation({ + summary: 'List notes for a reservation (with active count)', + description: 'Envelope is { notes, activeCount } — not { data }.', + }) @ApiQuery({ name: 'propertyId', required: true }) - @ApiResponse({ status: 200, description: 'Notes and active count' }) + @ApiResponse({ + status: 200, + description: 'Notes and active count', + schema: { + example: { + notes: [{ id: '…', body: 'VIP', isActive: true }], + activeCount: 1, + }, + }, + }) listNotes( @Param('id', ParseUUIDPipe) id: string, @Query('propertyId', ParseUUIDPipe) propertyId: string, diff --git a/apps/api/src/modules/reservation/reservation.service.ts b/apps/api/src/modules/reservation/reservation.service.ts index 7fd90ea1..8329966c 100644 --- a/apps/api/src/modules/reservation/reservation.service.ts +++ b/apps/api/src/modules/reservation/reservation.service.ts @@ -1250,11 +1250,13 @@ export class ReservationService { ratePlanName: r.ratePlanName, })); + const total = Number(countResult[0]?.count ?? 0); return { data, - total: Number(countResult[0]?.count ?? 0), + total, page, limit, + hasMore: page * limit < total, }; } diff --git a/docs/api-conventions.md b/docs/api-conventions.md new file mode 100644 index 00000000..84378c0f --- /dev/null +++ b/docs/api-conventions.md @@ -0,0 +1,72 @@ +# API conventions (staff REST) + +Integrator notes for HAIP’s staff API (`/api/v1/*`). Live field contracts remain in OpenAPI at `/docs`. This page documents shapes and asymmetries that cost failed dry-runs when they are only discoverable by trial and error (see [#321](https://github.com/TelivityAI/haip/issues/321)). + +Connect API (`/api/v1/connect/*`) is a separate surface (bearer confirmation / API key) and is not covered here. + +## 1. `propertyId` location + +| Operation style | Where `propertyId` goes | +|-----------------|-------------------------| +| Reads / list / get-by-id / most `:id` mutations | Query: `?propertyId=` | +| Creates that embed the tenant on the row | JSON body `propertyId` (preferred) | + +**Query alias on selected creates:** `POST /rooms/types`, `POST /rate-plans`, and `POST /reservations/:id/notes` also accept `?propertyId=` when the body omits it. If both are sent, they must match or the API returns `400`. + +Do not infer `propertyId` from another entity id (confused-deputy). Always send it on the request. + +## 2. Cancel reason field names + +`PATCH /reservations/:id/cancel` accepts an optional body: + +- `cancellationReason` — preferred for the staff API +- `reason` — alias (same meaning as Connect / bulk cancel) + +Empty body is allowed. Sending an undeclared field still fails validation (`forbidNonWhitelisted`). + +## 3. Non-modifiable fields on `PATCH /reservations/:id` + +Allowed: dates, room type, rate plan, total amount, occupancy, special requests, `doNotMove`. + +**Not patchable** (intentional provenance / lifecycle): + +- `source`, `channelCode` +- Status (use dedicated routes: confirm, assign, cancel, no-show, check-in, check-out, …) +- Primary guest / booking confirmation number + +Unknown body keys are rejected by the global validation pipe. + +## 4. Response envelopes (exceptions) + +There is **no** single global `{ data }` wrapper. Resource handlers return different top-level keys: + +| Endpoint | Top-level shape | +|----------|-----------------| +| `GET /reservations` | `{ data, total, page, limit, hasMore }` | +| `GET /reservations/:id` | `{ reservation, guest, roomType, ratePlan, room, confirmationNumber }` | +| `GET /reservations/:id/notes` | `{ notes, activeCount }` | +| Many list endpoints | `{ data, … }` or a bare array | + +Clients must read the documented key for each route. Do not iterate an object’s keys assuming the payload is a list. + +## 5. List pagination + +Reservation list defaults: `page=1`, `limit=20` (max 100). + +The list payload always includes: + +- `data` — current page +- `total` — full match count (use this to detect truncation) +- `page`, `limit` +- `hasMore` — `true` when `page * limit < total` + +A “list all” client must page until `hasMore` is false (or `data.length === 0`). + +## 6. External reservation reference + +`bookings.external_confirmation` is writable on **direct create** and **import**, not only channel inbound: + +- `POST /reservations` — optional body field `externalConfirmation` +- `POST /reservations/import` — per-row optional `externalConfirmation` (with `channelCode`, used for idempotent dedupe) + +Unknown field names are stripped/rejected; use camelCase `externalConfirmation` exactly. diff --git a/docs/integrations/README.md b/docs/integrations/README.md index 551eacfa..fe78ba74 100644 --- a/docs/integrations/README.md +++ b/docs/integrations/README.md @@ -8,6 +8,8 @@ For the full integration catalog, see **[Integration catalog](../INTEGRATIONS.md For event delivery (signatures, payloads, retries, subscriptions), start with **[Webhooks & events](../webhooks.md)**. +Staff REST request/response conventions (propertyId locations, envelopes, pagination, cancel aliases, external confirmation): **[API conventions](../api-conventions.md)**. + ## Recipes | Recipe | What it covers |