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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

<details>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/modules/reservation/reservation-ops.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
69 changes: 61 additions & 8 deletions apps/api/src/modules/reservation/reservation.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,24 +135,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,
Expand All @@ -163,7 +200,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' })
Expand Down Expand Up @@ -403,9 +444,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,
Expand Down
4 changes: 3 additions & 1 deletion apps/api/src/modules/reservation/reservation.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}

Expand Down
72 changes: 72 additions & 0 deletions docs/api-conventions.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions docs/integrations/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Loading