Skip to content

Interface Specification (API_SPEC)

Project: PTX Channel Manager (ptx-cm)
Version: 3.0.5
Date: 2026-06-05
Type: REST API (JSON)
Base URL: /api/v1

v3.0.5 Updates (2026-06-05):

  • Added POST /ota/trip/reservations endpoint (Trip.com inbound reservation push via OpenTravel XML)

v3.0.4 Updates (2026-06-05):

  • Entity internal code module implemented: all detail endpoints now accept internal code (preferred) OR UUID
  • Added GET /ota-listings/facets endpoint (lightweight filter options)
  • Added /ota-status/* endpoints (CRUD + soft-delete/restore for OTA status mappings)

v3.0.3 Updates (2026-06-04):

  • Added /supplier-apartments/* endpoints (list, filter-fields, groups, CRUD, clear-all)
  • Added /filter-views CRUD endpoints (saved filter views)

v3.0.2 Updates (2026-06-04):

  • Added /ota-listings/* CRUD endpoints (create, read, update, delete)
  • Added GET /properties/stats and GET /properties/facets endpoints (lightweight KPI/filter data)
  • Added GET /ota-connections/stats endpoint

v3.0.1 Updates:

  • Added /bookings/filter-fields endpoint (advanced filter field metadata)
  • Added af (advanced filters) param to GET /bookings

1. Authentication

All endpoints require JWT Bearer token unless marked [public].

POST /api/v1/auth/login [public]

Screen: S-01 | FR:

Login with email/password. Returns JWT access token + refresh token.

Request:

json
{
  "email": "string (required)",
  "password": "string (required)"
}

Response 200:

json
{
  "accessToken": "string (JWT)",
  "refreshToken": "string",
  "mustChangePassword": "boolean",
  "user": {
    "id": "string (uuid)",
    "email": "string",
    "name": "string",
    "role": "manager | staff",
    "country": "string (VN|ID|MY) | null",
    "locale": "string (vi|id|ms|en)"
  }
}

Response 401: { "error": "Invalid credentials" }

Note: If mustChangePassword: true, frontend should redirect to /change-password immediately after login.

POST /api/v1/auth/refresh [public]

Request: Empty body. Token passed via refresh_token HttpOnly cookie.
Response 200: { } (new tokens set in cookies)
Rate limit: 10/min per IP
Side effects:

  • New refresh token generated and stored in DB
  • Old refresh token remains valid (no rotation requirement)
  • Cookies set: access_token (15m), refresh_token (7d, path=/api/v1/auth/refresh)

POST /api/v1/auth/logout

Request: Empty body. Token passed via refresh_token HttpOnly cookie.
Response 204: No content
Side effects: Refresh token revoked in DB (jti deleted)

POST /api/v1/auth/forgot-password [public]

Screen: /forgot-password | FR: FR-20

Request password reset email. Public endpoint with email enumeration prevention (always returns success).

Request:

json
{
  "email": "string (required)"
}

Response 200: { "message": "If account exists, reset email sent" }

Rate limit: 3 tokens per user per hour (server-side)

Side effects:

  • If email exists: Generate reset token, send email with reset link
  • If email doesn't exist: Silent failure (no error to prevent enumeration)

POST /api/v1/auth/reset-password [public]

Screen: /reset-password | FR: FR-20

Complete password reset using token from email link. Public endpoint.

Request:

json
{
  "token": "string (required, from email link)",
  "newPassword": "string (required, min 8 chars)"
}

Response 200: { "message": "Password reset successful" }

Response 400: { "error": "Invalid or expired token" }

Side effects:

  • Password updated
  • Token marked as used
  • All refresh tokens revoked (session invalidation)

1.5 Security & Access Control

Authentication

All endpoints (except /auth/login and /auth/refresh) require a valid JWT access token in the Authorization: Bearer <token> header, or via access_token HttpOnly cookie.

JWT Payload:

json
{
  "sub": "user-id",
  "email": "user@example.com",
  "role": "manager | staff",
  "country": "VN | ID | MY | null",
  "locale": "vi | id | ms | en"
}

Country Scoping

  • Staff users (country != null in JWT): Auto-scoped to their assigned country. All queries automatically filtered.
  • Manager users (country == null in JWT): Can view all countries. Optional ?country=VN|ID|MY query parameter narrows scope.
  • Allowed countries: TH, VN, ID (validated in CountryScopeGuard)
  • Scope violation: Returns 403 Forbidden if accessing cross-country property/booking

Rate Limiting

EndpointLimitWindow
POST /auth/login5 requests60 seconds
POST /auth/refresh10 requests60 seconds
Global (all other)10 requests60 seconds

Role-Based Access

OperationRole RequiredNotes
Create propertymanagerStaff cannot create properties
Create OTA accountmanagerStaff cannot add OTA credentials
Update settingsmanagerGlobal config restricted
Delete usermanagerAccount deactivation only
List/filter own dataanyAll lists country-scoped

Entity Internal Codes

All detail endpoints (:id route parameters) accept internal codes (preferred) OR UUID (legacy for backward compatibility). Internal codes are human-readable identifiers auto-generated at entity creation:

EntityCode FormatExample
Property2-letter country code prefix + 6–8 hexMY-22846F
CustomerCU- + 6–8 hexCU-A3F92B
BookingBK- + 6–8 hexBK-7D21C4
Supplier ApartmentSA- + 6–8 hexSA-1B0C44
OTA Listing DetailLD- + 6–8 hexLD-F4A8C1
SupplierBusiness supplierCode (no fixed shape)HCM001
DepartmentDepartment.code (resolved company-scoped)CS

Codes follow XX-HHHHHH (6 hex default, extended to 8 hex on collision) per ENTITY_CODE_PREFIXES in packages/types/src/entity-codes.ts. Supplier and Department are resolvable via their existing business codes but have no stored internalCode column.

Usage:

  • Route parameters: Accept either: GET /properties/MY-22846F or GET /properties/{uuid}
  • Resolution: Server-side EntityCodeService translates code → UUID before querying
  • Request bodies/queries: Always carry real UUID from fetched entity; never send internal codes in request payloads

Implementation reference: See apps/api/src/common/entity-code/entity-code.service.ts and apps/web/lib/routes.ts (client-side route builders).


2. Dashboard

GET /api/v1/dashboard/summary

Screen: S-02 | FR: FR-06

Returns KPI cards and sync overview. Filtered by user's country if scoped.

Query params:

  • country (optional, managers only): VN|ID|MY — filter by country

Response 200:

json
{
  "kpi": {
    "propertiesOnline": "number",
    "propertiesTotal": "number",
    "todayBookings": "number",
    "syncHealthPercent": "number (0-100)",
    "activeAlerts": "number"
  },
  "recentBookings": [
    {
      "id": "string",
      "propertyName": "string",
      "guestName": "string",
      "otaType": "booking | trip | expedia | agoda_hotel | agoda_homes",
      "checkIn": "string (ISO date)",
      "checkOut": "string (ISO date)",
      "totalAmount": "number (integer, smallest currency unit)",
      "currency": "string (VND|IDR|MYR|USD)",
      "status": "confirmed | cancelled | no_show",
      "createdAt": "string (ISO datetime)"
    }
  ],
  "syncStatus": [
    {
      "propertyId": "string",
      "propertyName": "string",
      "connections": [
        {
          "otaType": "string",
          "status": "active | expired | error | requires_2fa",
          "lastSyncAt": "string (ISO datetime) | null",
          "pendingJobs": "number"
        }
      ]
    }
  ]
}

GET /api/v1/dashboard/sync-status

Screen: S-02 (sidebar) | FR: FR-06

Lightweight endpoint for sidebar polling (every 10s). Country-scoped.

Query params:

  • country (optional, managers only): VN|ID|MY

Response 200:

json
{
  "connections": [
    {
      "id": "string",
      "otaType": "string",
      "propertyId": "string",
      "status": "active | expired | error | requires_2fa",
      "lastSyncAt": "string | null"
    }
  ]
}

GET /api/v1/activity-logs

Screen: S-02 (activity log panel, super admin only) | FR: FR-25

Retrieve recent HTTP request activity logs. Accessible only to super_admin role. Logs are read from the activity_logs PostgreSQL table, returning the latest N entries sorted by createdAt descending.

Access Control: @SuperAdminGuard (checks roleName === 'super_admin')

Query params:

  • limit (optional, default 100): Number of recent entries to return, clamped to 1-500

Response 200:

json
{
  "entries": [
    {
      "timestamp": "string (ISO datetime, e.g., 2026-02-12T15:30:45.123Z)",
      "email": "string (user email)",
      "method": "string (GET | POST | PATCH | PUT | DELETE)",
      "path": "string (request path, e.g., /api/v1/properties)",
      "status": "number (HTTP status code, e.g., 200, 404, 500)",
      "screen": "string (Frontend screen identifier, e.g., S-02, S-08, or empty)"
    }
  ],
  "totalAvailable": "number (total entries available in log file)"
}

Response 403: { "error": "Insufficient permissions", "code": "FORBIDDEN" } - Returned if user is not super_admin

Example Activity Entry Format:

2026-02-12T15:30:45.123Z | admin@ptx.com | GET | /api/v1/dashboard/summary | 200 | S-02
2026-02-12T15:30:42.456Z | staff@ptx.com | POST | /api/v1/bookings | 201 | S-08
2026-02-12T15:30:38.789Z | admin@ptx.com | DELETE | /api/v1/properties/123 | 204 | S-03

Implementation Details:

  • Middleware activity-log.middleware.ts records all HTTP requests to the activity_logs table via Prisma
  • Format: DB row converted to { timestamp, user, method, path, status, screen }
  • Excludes activity-logs endpoint itself from logging (prevents recursion)
  • Controlled by ACTIVITY_LOG_ENABLED environment variable (default: true)
  • Uses createMany for batched client events

POST /api/v1/activity-logs/events

Screen: (client-side) | FR: FR-35

Client-side batched event submission. Used by frontend to log UI events/screen views. Authenticated.

Request:

json
{
  "events": [
    { "method": "CLIENT", "path": "/properties", "status": 0, "screen": "S-03" }
  ]
}

Response 201: { "accepted": <count> }

Implementation: apps/api/src/modules/activity-logs/activity-logs.controller.ts:59


3. Properties

GET /api/v1/properties

Screen: S-03 | FR: FR-05

Query params:

  • country (optional, managers only): VN|ID|MY — manager filter; staff auto-scoped
  • status (optional): active | inactive
  • search (optional): Search by name
  • page (default 1): Page number
  • limit (default 25): Items per page
  • sortBy (default name): Field to sort by — whitelisted to prevent injection
  • sortOrder (default asc): asc | desc

Access Control:

  • Staff see only properties in their country
  • Managers see all countries (can narrow with ?country=)
  • Attempting to view cross-country property returns 403

Response 200:

json
{
  "data": [
    {
      "id": "string",
      "name": "string",
      "country": "string (VN|ID|MY)",
      "timezone": "string (Asia/Ho_Chi_Minh, etc.)",
      "currency": "string (VND|IDR|MYR)",
      "address": "string | null",
      "isActive": "boolean",
      "otaConnections": [
        { "otaType": "string", "status": "string" }
      ],
      "syncStatus": "ok | warning | error"
    }
  ],
  "pagination": {
    "page": "number",
    "limit": "number",
    "total": "number",
    "totalPages": "number"
  }
}

POST /api/v1/properties

Screen: S-04 | FR: FR-05 | Role: U-01

Request:

json
{
  "name": "string (required)",
  "country": "string (required, VN|ID|MY)",
  "timezone": "string (required)",
  "currency": "string (required, VND|IDR|MYR)",
  "address": "string (optional)"
}

Response 201: Property object

GET /api/v1/properties/stats

Screen: S-03 | FR: FR-05

Aggregated KPI stats for property list KPI cards. Used by frontend list pages to avoid full-data fetch.

Query params:

  • country (optional, managers only): VN|ID|MY — filter by country

Response 200:

json
{
  "total": "number (narrowed by ?country)",
  "byCountry": { "MY": "number", "VN": "number" }
}

Note: Response ~100B vs 2–3.5MB for full paginated list. byCountry ignores ?country (filter chips need all countries within the scope guard). Single country only.

GET /api/v1/properties/facets

Screen: S-03 | FR: FR-05

Distinct filter options for list filter dropdowns. Payload ~1KB.

Query params: none (country scope guard auto-applies)

Response 200:

json
{
  "cities": ["Hanoi", "Kuala Lumpur"],
  "countries": ["MY", "VN"]
}

GET /api/v1/properties/base-rate-matrix

Screen: S-11 | Role: PROPERTIES.VIEW

Returns property × room-type × OTA base-rate matrix (country-scoped). Used by bulk rate editor.

Response 200: Nested matrix grouped by property → room types → OTA connections with current base rate.

Implementation: apps/api/src/modules/properties/properties.controller.ts:30

GET /api/v1/properties/:id

Screen: S-04 | FR: FR-05

Route Parameter :id:

  • Accepts internal code (preferred, e.g. MY-22846F) OR UUID (legacy, always works for backward compatibility)
  • Resolved by EntityCodeService to the property's real UUID before querying

Response 200: Full property object with room types and OTA connections

json
{
  "id": "string",
  "name": "string",
  "country": "string",
  "timezone": "string",
  "currency": "string",
  "address": "string | null",
  "isActive": "boolean",
  "roomTypes": [
    {
      "id": "string",
      "name": "string",
      "baseRate": "number (integer)",
      "totalRooms": "number",
      "maxOccupancy": "number",
      "isActive": "boolean"
    }
  ],
  "otaConnections": [
    {
      "id": "string",
      "otaAccountId": "string",
      "otaAccountLabel": "string",
      "otaType": "string",
      "otaPropertyId": "string",
      "status": "active | expired | error | requires_2fa",
      "lastSyncAt": "string | null",
      "roomMappings": [
        {
          "id": "string",
          "roomTypeId": "string",
          "roomTypeName": "string",
          "otaRoomId": "string",
          "otaRatePlanId": "string"
        }
      ]
    }
  ]
}

PUT /api/v1/properties/:id

Screen: S-04 | FR: FR-05

Request: Same fields as POST (partial update supported)
Response 200: Updated property object

DELETE /api/v1/properties/:id

Screen: S-03 | FR: FR-05 | Role: U-01

Response 204: No content (soft delete, sets isActive=false)


4. Room Types

POST /api/v1/properties/:propertyId/room-types

Screen: S-04 | FR: FR-05

Request:

json
{
  "name": "string (required)",
  "baseRate": "number (integer, required)",
  "totalRooms": "number (required)",
  "maxOccupancy": "number (required)"
}

Response 201: RoomType object

PUT /api/v1/properties/:propertyId/room-types/:id

Request: Partial update of room type fields
Response 200: Updated room type

DELETE /api/v1/properties/:propertyId/room-types/:id

Response 204: No content (soft delete)


5. OTA Accounts

GET /api/v1/ota-accounts

Screen: S-05 | FR: FR-01

List all OTA accounts owned by current user. Country-scoped for staff.

Query params:

  • country (optional, auto-applied for staff): VN|ID|MY
  • status (optional): active | expired | error | requires_2fa

Response 200:

json
{
  "data": [
    {
      "id": "string",
      "otaType": "booking | trip | expedia | agoda_hotel | agoda_homes",
      "label": "string",
      "status": "active | expired | error | requires_2fa",
      "twoFactorMethod": "none | totp | manual",
      "lastSessionRefresh": "string (ISO datetime) | null",
      "countryCode": "string (VN|ID|MY) | null",
      "propertyCount": "number",
      "createdAt": "string (ISO datetime)"
    }
  ]
}

POST /api/v1/ota-accounts

Screen: S-06 | FR: FR-01

Create new OTA account with credentials.

Request:

json
{
  "otaType": "booking | trip | expedia | agoda_hotel | agoda_homes (required)",
  "label": "string (required)",
  "credentials": {
    "username": "string (required)",
    "password": "string (required)"
  },
  "twoFactorMethod": "none | totp | manual (required)",
  "totpSecret": "string (optional, required if twoFactorMethod=totp)",
  "countryCode": "string (optional, VN|ID|MY). OTA×country matrix validation enforced when COUNTRY_SCOPE_ENABLED."
}

Response 201: OtaAccount object (credentials excluded)

GET /api/v1/ota-accounts/:id

Screen: S-05 | FR: FR-01

Response 200:

json
{
  "id": "string",
  "otaType": "string",
  "label": "string",
  "status": "string",
  "twoFactorMethod": "string",
  "lastSessionRefresh": "string | null",
  "countryCode": "string | null",
  "properties": [
    {
      "connectionId": "string",
      "propertyId": "string",
      "propertyName": "string",
      "otaPropertyId": "string"
    }
  ],
  "createdAt": "string"
}

PUT /api/v1/ota-accounts/:id

Screen: S-05 | FR: FR-01

Update credentials, label, or 2FA settings.

Request: Partial update (same fields as POST, all optional)
Response 200: Updated OtaAccount object

DELETE /api/v1/ota-accounts/:id

Screen: S-05 | FR: FR-01

Response 204: No content (cascades to ota_connections)

POST /api/v1/ota-accounts/:id/test

Screen: S-06 | FR: FR-01

Test connection by attempting login. Returns session validity.

Response 200:

json
{
  "success": "boolean",
  "status": "active | expired | error | requires_2fa",
  "message": "string",
  "sessionExpiresAt": "string (ISO datetime) | null"
}

POST /api/v1/ota-accounts/:id/refresh-session

Screen: S-05, S-02 | FR: FR-01

Force session refresh for the account.

Response 200: { "status": "active", "lastSessionRefresh": "string" }
Response 401: { "error": "Manual re-login required", "reason": "2FA challenge" }

GET /api/v1/ota-accounts/:id/discover-properties

Screen: S-07 | FR: FR-02

Fetch available properties from OTA extranet for this account.

Response 200:

json
{
  "properties": [
    {
      "otaPropertyId": "string",
      "name": "string",
      "address": "string | null",
      "country": "string (VN|ID|MY) | null",
      "alreadyImported": "boolean",
      "matchedPropertyId": "string | null"
    }
  ]
}

POST /api/v1/ota-accounts/:id/import-properties

Screen: S-07 | FR: FR-02

Import selected properties from OTA. Auto-creates Property + RoomTypes + OtaConnection + OtaRoomMappings.

Request:

json
{
  "properties": [
    {
      "otaPropertyId": "string (required)",
      "existingPropertyId": "string | null",
      "action": "create_new | link_existing"
    }
  ]
}

Response 200:

json
{
  "imported": [
    {
      "propertyId": "string",
      "propertyName": "string",
      "connectionId": "string",
      "roomTypesCreated": "number",
      "mappingsCreated": "number"
    }
  ],
  "errors": [
    {
      "otaPropertyId": "string",
      "error": "string"
    }
  ]
}

6. OTA Connections

GET /api/v1/ota-connections

Screen: S-15 (OTA Properties) | FR:

List OTA connections (Lark OTA listings) with pagination, search, and filtering. Country-scoped.

Query params:

  • country (optional, auto-applied for staff): VN|ID|MY
  • channel (optional): OTA platform filter
  • city (optional): City filter (cascading)
  • ptxSync (optional): 'DONE' | '' (mapped vs unmapped)
  • search (optional): Search by name, property ID, etc.
  • page (default 1), limit (default 20)
  • sortBy (optional): whitelisted — building | city | otaStatus | createdAt (relation sorts resolved server-side; omitted → createdAt desc)
  • sortOrder (optional, default asc when sortBy given): 'asc' | 'desc'

Response 200:

json
{
  "data": [
    {
      "id": "uuid",
      "otaPropertyId": "string",
      "otaPropertyName": "string",
      "propertyId": "uuid | null (null if unmapped)",
      "otaAccountId": "uuid",
      "channel": "string",
      "city": "string",
      "ptxSync": "DONE | '' (empty)",
      "otaStatus": "string (Lark enum value)",
      "createdAt": "timestamp"
    }
  ],
  "pagination": { "page": 0, "limit": 0, "total": 0, "totalPages": 0 }
}

GET /api/v1/ota-connections/stats

Aggregated OTA connections KPI stats for list page.

Query params:

  • country (optional, auto-applied for staff): VN|ID|MY

Response 200:

json
{
  "total": "number (narrowed by ?country)",
  "synced": "number (ptxSync = DONE)",
  "activeOnOta": "number (otaStatus starts with '2.')",
  "unmapped": "number (propertyId = null)",
  "byCountry": { "MY": "number", "SG": "number" }
}

A connection's country = COALESCE(property.country, otaAccount.countryCode). byCountry ignores ?country (chips need all countries within the scope guard); sum(byCountry) may be < total when both sources are null. The city/country filter dropdowns on this page use GET /properties/facets (there is no ota-connections facets endpoint).

POST /api/v1/ota-connections

Screen: S-04, S-07 | FR: FR-02

Create lightweight link between property and OTA account.

Request:

json
{
  "propertyId": "string (required)",
  "otaAccountId": "string (required)",
  "otaPropertyId": "string (required)"
}

Response 201: OtaConnection object

PATCH /api/v1/ota-connections/:id

Screen: S-15 | FR:

Update OTA listing metadata (Lark-sourced fields). Save-on-blur per field. Country-scoped.

Request (partial update):

json
{
  "otaPropertyName": "string (MaxLength 255)",
  "ptxSync": "DONE | ''",
  "bank": "string (optional)",
  "reviewScore": "number (optional)",
  "otaStatus": "string (enum value from Lark)"
}

Response 200: Updated OtaConnection object

Note: Only changed fields are included in request body. Backend returns 400 if unknown fields included or validation fails (e.g., otaStatus not in whitelist).

PUT /api/v1/ota-connections/:id/map

Screen: S-15 | FR:

Link/unlink a building to/from an OTA listing. Country-scoped.

Request (link):

json
{
  "propertyId": "uuid (required)"
}

Request (unlink):

json
{
  "propertyId": null
}

Response 200: Updated OtaConnection object

DELETE /api/v1/ota-connections/:id

Screen: S-04 | FR: FR-02

Response 204: No content


7. OTA Listings (Source List)

GET /api/v1/ota-listings

Screen: S-19 (Source Listings) | FR:

List OTA listings (Source List) imported from Lark with pagination, search, and filtering. Country-scoped.

Query params:

  • page (default 1), limit (default 20)
  • search (optional): Search by name or internal code
  • propertyId (optional): Filter by property UUID
  • currencyName (optional): Filter by currency (e.g., VND, IDR, MYR)
  • status (optional, default active): active | inactive | all — soft-delete visibility
  • sortBy (optional, default updatedAt): Whitelisted fields — name | internalCode | costWd | costWk | maxPax | currencyName | updatedAt | createdAt
  • sortOrder (optional, default asc): asc | desc

Response 200:

json
{
  "data": [
    {
      "id": "uuid",
      "internalCode": "string (LD-xxxxxx)",
      "name": "string",
      "description": "string | null",
      "propertyId": "uuid | null",
      "roomTypeId": "uuid",
      "costWd": "decimal (weekday cost)",
      "costWk": "decimal (weekend cost)",
      "currencyName": "string (VND|IDR|MYR)",
      "currencyRate": "decimal",
      "marginLowSs": "decimal (fraction 0–1)",
      "marginHighSs": "decimal (fraction 0–1)",
      "marginHoli": "decimal (fraction 0–1)",
      "levelSource": "decimal | null",
      "checkInFile": "string | null",
      "restrictionRules": "string | null",
      "checkInWay": "string | null",
      "checkInOutPolicy": "string | null",
      "childPolicy": "string | null",
      "cleaningFee": "decimal | null",
      "roomSize": "decimal | null",
      "bedType": "string | null",
      "bathrooms": "decimal | null",
      "maxPax": "integer | null",
      "roomPhotos": "string[] | null",
      "amenities": "object (key-value flags)",
      "isActive": "boolean (soft-delete state)",
      "createdAt": "timestamp",
      "updatedAt": "timestamp"
    }
  ],
  "pagination": { "page": 0, "limit": 0, "total": 0, "totalPages": 0 }
}

GET /api/v1/ota-listings/facets

Screen: S-19 (Source Listings) | FR:

Aggregated facets for list page filters.

Response 200:

json
{
  "currencies": ["VND", "IDR", "MYR"],
  "statuses": [
    { "value": "active", "label": "Active", "count": "number" },
    { "value": "inactive", "label": "Inactive (Deleted)", "count": "number" }
  ]
}

GET /api/v1/ota-listings/:id

Screen: S-19-Detail (Source Listing Detail) | FR:

Fetch a single OTA listing by UUID or internal code. Route parameter accepts both.

Response 200: OtaListingDetail object (see GET /api/v1/ota-listings for schema)

POST /api/v1/ota-listings

Role: OTA_CONNECTIONS CREATE

Create a new OTA listing manually.

Request:

json
{
  "name": "string (required, max 255)",
  "propertyId": "uuid (required)",
  "roomTypeId": "uuid (required)",
  "costWd": "number (required, ≥ 0)",
  "costWk": "number (required, ≥ 0)",
  "currencyName": "string (required, e.g., VND)",
  "currencyRate": "number (required, ≥ 0)",
  "marginLowSs": "number (required, 0–1, stored as fraction)",
  "marginHighSs": "number (required, 0–1, stored as fraction)",
  "marginHoli": "number (required, 0–1, stored as fraction)",
  "description": "string (optional)",
  "levelSource": "number (optional)",
  "checkInFile": "string (optional)",
  "restrictionRules": "string (optional)",
  "checkInWay": "string (optional)",
  "checkInOutPolicy": "string (optional)",
  "childPolicy": "string (optional)",
  "cleaningFee": "number (optional, ≥ 0)",
  "roomSize": "number (optional, ≥ 0)",
  "bedType": "string (optional)",
  "bathrooms": "number (optional, ≥ 0)",
  "maxPax": "integer (optional, ≥ 0)",
  "roomPhotos": "string[] (optional)",
  "amenities": "object (optional, key-value)"
}

Response 201: Created OtaListingDetail object with auto-generated internalCode (LD prefix)

Note: internalCode is auto-generated unique identifier. propertyId and roomTypeId must exist.

PATCH /api/v1/ota-listings/:id

Role: OTA_CONNECTIONS EDIT

Partial update of OTA listing. Save-on-blur per field supported. Restore soft-deleted listing via { "isActive": true }.

Request (all fields optional):

json
{
  "name": "string (max 255)",
  "propertyId": "uuid",
  "roomTypeId": "uuid",
  "costWd": "number (≥ 0)",
  "costWk": "number (≥ 0)",
  "currencyName": "string",
  "currencyRate": "number (≥ 0)",
  "marginLowSs": "number (0–1)",
  "marginHighSs": "number (0–1)",
  "marginHoli": "number (0–1)",
  "description": "string | null",
  "levelSource": "number | null",
  "checkInFile": "string | null",
  "restrictionRules": "string | null",
  "checkInWay": "string | null",
  "checkInOutPolicy": "string | null",
  "childPolicy": "string | null",
  "cleaningFee": "number | null",
  "roomSize": "number | null",
  "bedType": "string | null",
  "bathrooms": "number | null",
  "maxPax": "integer | null",
  "roomPhotos": "string[] | null",
  "amenities": "object | null",
  "isActive": "boolean (restore deleted)"
}

Response 200: Updated OtaListingDetail object

DELETE /api/v1/ota-listings/:id

Role: OTA_CONNECTIONS DELETE

Soft delete OTA listing (sets isActive: false). Restore via PATCH { "isActive": true }.

Response 204: No content


8. OTA Room Mappings

PUT /api/v1/ota-connections/:connectionId/room-mappings

Screen: S-07 | FR: FR-02

Bulk upsert room mappings.

Request:

json
{
  "mappings": [
    {
      "roomTypeId": "string (required)",
      "otaRoomId": "string (required)",
      "otaRatePlanId": "string (required)"
    }
  ]
}

Response 200: { "mappings": [...] }

GET /api/v1/ota-accounts/:accountId/ota-room-types

Screen: S-07 | FR: FR-02

Fetch available room types from OTA extranet for a specific property via this account.

Query params:

  • otaPropertyId (required): OTA's internal property ID

Response 200:

json
{
  "otaRoomTypes": [
    {
      "otaRoomId": "string",
      "name": "string",
      "ratePlans": [
        { "otaRatePlanId": "string", "name": "string" }
      ]
    }
  ]
}

8.5 OTA Inbound Reservations (Trip.com Push)

POST /api/v1/ota/trip/reservations [public]

Screen: (system integration) | FR:

Inbound webhook for Trip.com to push reservation XML (OTA_HotelResRQ / OTA_HotelResModifyRQ / OTA_CancelRQ). Dispatch by root element; processes synchronously and answers XML RS. Authentication is POS credentials in the XML (BasicPropertyInfo/Requestor @ID/@MessagePassword), NOT JWT.

Endpoints:

  • Production: https://cms.ptxsolution.com/api/v1/ota/trip/reservations
  • Staging: https://cms-st.ptxsolution.com/api/v1/ota/trip/reservations

Auth: POS RequestorID (@ID) + MessagePassword (@MessagePassword) validated against TRIP_INBOUND_AUTH_ID and TRIP_INBOUND_AUTH_PASSWORD env vars (constant-time HMAC-SHA256 compare).

Request Content-Type: text/xml or application/xml

Request body: XML conforming to one of:

  • OTA_HotelResRQ — New reservation
  • OTA_HotelResModifyRQ — Modify existing reservation
  • OTA_CancelRQ — Cancel reservation (stub: Type=2 "No implementation" until Trip.com publishes schema)

Response 200: Always HTTP 200, regardless of outcome (OTA convention). Body is XML RS with one of:

  • <OTA_HotelResRS><Success><HotelReservation ResStatus="S"><UniqueID Type="14" ID="{Booking.internalCode}"/></HotelReservation></Success></OTA_HotelResRS> (new creation)
  • <OTA_HotelResRS><Success><Warning Type="3" Code="127"/></Success></OTA_HotelResRS> (duplicate/concurrent retry dedup via DB unique constraint)
  • <OTA_HotelResRS><Success><Warning Type="3" Code="321"/></Success></OTA_HotelResRS> (modify with no changes)
  • <OTA_HotelResRS><Errors><Error Type="3" Code="361"/></Errors></OTA_HotelResRS> (validation failure — see runbook for full error mapping)

Side effects:

  • Raw XML (PAN-redacted) persisted to ota_inbound_messages table (durable log, before any processing)
  • Booking created or updated with guest email, phone, room count, guest count, special requests, and PAN-masked rawData
  • Alert created on property if internal failure (Type=12 Code=450)
  • Email sent to TRIP_INBOUND_OPS_EMAIL on internal error (fire-and-forget; silently skips if SMTP unconfigured)

Key validations (fail-fast, first error stops processing):

  • OtaConnection exists for hotel code (HotelCode) and is linked to a property (propertyId not null)
  • Room type mapping exists for OtaRoomId
  • Rate plan mapping exists for OtaRatePlanId
  • Check-in/check-out dates are parseable and checkOut is not in past
  • Guest counts and ages are valid
  • Currency code is 3-letter ISO

Reference: Full error mapping, validation details, and deployment runbook in docs/ota-trip-inbound-reservation.md.


9. Bookings

GET /api/v1/bookings

Screen: S-08 | FR: FR-03, FR-41

Query params:

  • country (optional, auto-applied for staff): VN|ID|MY
  • propertyId (optional)
  • otaType (optional, comma-separated)
  • status (optional): confirmed | cancelled | no_show
  • otaStatus (optional): raw OTA status string filter (FR-41)
  • checkInFrom (optional): ISO date
  • checkInTo (optional): ISO date
  • search (optional): Guest name search
  • af (optional): Advanced filters (URL-encoded JSON: FilterCondition[]). Example: ?af=%5B%7B%22field%22%3A%22status%22%2C%22op%22%3A%22is%22%2C%22value%22%3A%22confirmed%22%7D%5D = [{"field":"status","op":"is","value":"confirmed"}]. See /bookings/filter-fields for available fields and operators.
  • page (default 1)
  • limit (default 25)
  • sortBy (default createdAt): Field name
  • sortOrder (default desc): asc | desc

Response 200:

json
{
  "data": [
    {
      "id": "string",
      "propertyId": "string",
      "propertyName": "string",
      "roomTypeId": "string",
      "roomTypeName": "string",
      "otaType": "string",
      "otaBookingId": "string",
      "guestName": "string",
      "guestEmail": "string | null",
      "checkIn": "string (ISO date)",
      "checkOut": "string (ISO date)",
      "numRooms": "number",
      "numGuests": "number",
      "status": "string",
      "otaStatus": "string | null",
      "otaStatusUpdatedAt": "string (ISO datetime) | null",
      "totalAmount": "number (integer)",
      "currency": "string",
      "createdAt": "string (ISO datetime)",
      "bookingHealth": "ok | warning | risk (computed from settings.healthFlagRules)",
      "healthFlags": "Array<{ name: string, severity: 'warning'|'risk', message: string }> (computed)"
    }
  ],
  "pagination": { "page": 0, "limit": 0, "total": 0, "totalPages": 0 }
}

Note: bookingHealth and healthFlags are computed at runtime from rules in settings.healthFlagRules (not stored on booking rows). See FR-51 / DB_DESIGN §3.10a.

GET /api/v1/bookings/:id

Screen: S-09 | FR: FR-03, FR-41

Response 200:

json
{
  "id": "string",
  "propertyId": "string",
  "propertyName": "string",
  "roomTypeId": "string",
  "roomTypeName": "string",
  "otaType": "string",
  "otaBookingId": "string",
  "guestName": "string",
  "guestEmail": "string | null",
  "checkIn": "string (ISO date)",
  "checkOut": "string (ISO date)",
  "numRooms": "number",
  "numGuests": "number",
  "status": "string",
  "otaStatus": "string | null",
  "otaStatusUpdatedAt": "string (ISO datetime) | null",
  "totalAmount": "number (integer)",
  "currency": "string",
  "rawData": "object (original OTA response)",
  "createdAt": "string (ISO datetime)",
  "syncHistory": [
    {
      "otaType": "string",
      "action": "push_availability",
      "status": "completed | failed",
      "timestamp": "string",
      "error": "string | null"
    }
  ]
}

GET /api/v1/bookings/filter-fields

Screen: (filter UI) | FR:

Returns metadata for advanced filter fields available on bookings list. Public endpoint, gated by Bookings VIEW permission.

Response 200:

json
{
  "fields": [
    {
      "name": "propertyId",
      "type": "string",
      "label": "Property",
      "operators": ["is", "isNot"],
      "relatedModule": "properties"
    },
    {
      "name": "status",
      "type": "enum",
      "label": "Booking Status",
      "operators": ["is", "isNot", "isAnyOf", "isNoneOf"],
      "valueOptions": ["confirmed", "cancelled", "no_show"]
    },
    {
      "name": "checkIn",
      "type": "date",
      "label": "Check-In Date",
      "operators": ["is", "isBefore", "isAfter"]
    }
  ]
}

Note: Field metadata does NOT include prismaPath (internal implementation detail). Use name to reference in FilterCondition.field.

GET /api/v1/bookings/export

Screen: S-08 | FR: FR-03

Same filters as GET /bookings (including af param). Returns CSV file.

Response 200: Content-Type: text/csv

GET /api/v1/bookings/:id/history

Screen: S-09 | FR: FR-03

Retrieve audit log entries for a specific booking. Returns up to 50 most recent entries.

Response 200:

json
{
  "data": [
    {
      "id": "string",
      "action": "create | update | delete",
      "oldValue": "object | null",
      "newValue": "object | null",
      "performer": {
        "name": "string",
        "email": "string"
      },
      "createdAt": "string (ISO datetime)"
    }
  ]
}

PATCH /api/v1/bookings/:id/status

Screen: S-09 | FR: FR-28 | Role: Bookings EDIT

Change booking status via workflow transition. Validates transition is permitted for current user's role.

Request:

json
{
  "status": "string (required, target status key)",
  "note": "string (optional)"
}

Response 200: Updated booking object
Response 400: Invalid transition or target status not found
Response 403: User's role not permitted for this transition

Side effects:

  • Audit log entry created
  • Workflow transition hooks executed (audit_log, update_availability, send_notification)

PATCH /api/v1/bookings/:id/revert

Screen: S-09 | FR: FR-28 | Role: Bookings EDIT

Force-revert booking to its previous status, bypassing workflow transition rules. Looks up the last audit log entry and restores oldValue.status.

Request: { "note": "string (optional)" }

Response 200: { "id": "string", "status": "string (reverted status)", "previousStatus": "string (status before revert)" }
Response 400: No history found or booking is already in previous status

Side effects: Audit log entry created with revert: true flag in newValue


10. Availability

GET /api/v1/availability

Screen: S-10 | FR: FR-10

Query params:

  • propertyId (required)
  • startDate (required): ISO date
  • endDate (required): ISO date

Response 200:

json
{
  "propertyId": "string",
  "roomTypes": [
    {
      "roomTypeId": "string",
      "roomTypeName": "string",
      "totalRooms": "number",
      "dates": [
        {
          "date": "string (ISO date)",
          "totalRooms": "number",
          "bookedRooms": "number",
          "blockedRooms": "number",
          "availableRooms": "number"
        }
      ]
    }
  ]
}

PUT /api/v1/availability/block

Screen: S-10 | FR: FR-10

Block/unblock rooms. Triggers availability push to all OTAs.

Request:

json
{
  "roomTypeId": "string (required)",
  "date": "string (ISO date, required)",
  "blockedRooms": "number (required, 0 to unblock)"
}

Response 200: Updated availability object
Side effect: Queues push_availability jobs for all connected OTAs


11. Rates

GET /api/v1/rates

Screen: S-11 | FR: FR-09

Query params:

  • propertyId (required)
  • roomTypeId (required)
  • startDate (required)
  • endDate (required)

Response 200:

json
{
  "roomTypeId": "string",
  "baseRate": "number (integer)",
  "currency": "string",
  "dates": [
    {
      "date": "string (ISO date)",
      "baseRate": "number",
      "otaRates": [
        {
          "otaType": "string",
          "otaConnectionId": "string",
          "rateAmount": "number (integer)",
          "currency": "string"
        }
      ]
    }
  ]
}

PUT /api/v1/rates/bulk-update

Screen: S-11 | FR: FR-09

Push rates to selected OTAs for date range.

Request:

json
{
  "roomTypeId": "string (required)",
  "startDate": "string (ISO date, required)",
  "endDate": "string (ISO date, required)",
  "baseRate": "number (integer, required)",
  "otaAdjustments": [
    {
      "otaConnectionId": "string",
      "adjustmentType": "markup | discount | fixed",
      "adjustmentValue": "number"
    }
  ]
}

Response 202: { "jobIds": ["string"], "message": "Rate push jobs queued" }

GET /api/v1/rates/parity

Screen: S-13 | FR: FR-13

Query params: propertyId, startDate, endDate, mismatchOnly (boolean)

Response 200:

json
{
  "comparisons": [
    {
      "roomTypeName": "string",
      "date": "string",
      "rates": [
        { "otaType": "string", "rateAmount": "number", "currency": "string" }
      ],
      "parityStatus": "match | mismatch",
      "maxDiffPercent": "number"
    }
  ]
}

10.5 Bulk Rates

POST /api/v1/bulk-rates/preview-base-rate

Screen: /rates/bulk-ops | FR: FR-09 | Permission: RATES:VIEW

Preview the effect of a bulk base-rate adjustment across properties. Returns current vs proposed rates for each affected room type. Does not persist changes.

Request:

json
{
  "propertyIds": ["string (uuid, required)[]"],
  "adjustmentMode": "percentage | fixed | increment (required)",
  "adjustmentValue": "number (required)",
  "roomTypeNameFilter": "string (optional, partial match)"
}

Response 200:

json
[
  {
    "propertyName": "string",
    "roomTypeName": "string",
    "currentRate": "number (integer)",
    "newRate": "number (integer)"
  }
]

POST /api/v1/bulk-rates/update-base-rate

Screen: /rates/bulk-ops | FR: FR-09 | Permission: RATES:EDIT

Apply bulk base-rate adjustment. Updates roomType.baseRate for all matching room types across selected properties.

Request: Same as preview-base-rate

Response 200: { "updated": "number (count of room types updated)" }

POST /api/v1/bulk-rates/create-rules

Screen: /rates/bulk-ops | FR: FR-15 | Permission: RATES:EDIT

Create rate rules (markup/discount/seasonal) across multiple properties at once.

Request:

json
{
  "propertyIds": ["string (uuid, required)[]"],
  "ruleType": "markup | discount | seasonal (required)",
  "value": "number (required, min 0)",
  "startDate": "string (ISO date, optional)",
  "endDate": "string (ISO date, optional)",
  "roomTypeNameFilter": "string (optional)"
}

Response 200: { "created": "number (count of rules created)" }

POST /api/v1/bulk-rates/create-plans

Screen: /rates/bulk-ops | FR: FR-09 | Permission: RATES:EDIT

Create rate plans across multiple properties with pricing derived from base rate.

Request:

json
{
  "propertyIds": ["string (uuid, required)[]"],
  "planName": "string (required)",
  "priceMode": "fixed | offset_percent | offset_amount (required)",
  "priceValue": "number (required, min 0)",
  "roomTypeNameFilter": "string (optional)"
}

Response 200: { "created": "number (count of plans created)" }

12. Rate Rules

GET /api/v1/rate-rules

Screen: S-15 | FR: FR-15

Query params: propertyId (optional), isActive (optional)

Response 200:

json
{
  "data": [
    {
      "id": "string",
      "propertyId": "string",
      "propertyName": "string",
      "roomTypeId": "string | null",
      "roomTypeName": "string | null",
      "otaConnectionId": "string | null",
      "otaType": "string | null",
      "ruleType": "markup | discount | seasonal",
      "value": "number",
      "startDate": "string | null",
      "endDate": "string | null",
      "isActive": "boolean"
    }
  ]
}

POST /api/v1/rate-rules

Request:

json
{
  "propertyId": "string (required)",
  "roomTypeId": "string | null (null = all room types)",
  "otaConnectionId": "string | null (null = all OTAs)",
  "ruleType": "markup | discount | seasonal (required)",
  "value": "number (required, percentage or fixed)",
  "startDate": "string | null",
  "endDate": "string | null",
  "isActive": "boolean (default true)"
}

Response 201: RateRule object

PUT /api/v1/rate-rules/:id

Partial update.

DELETE /api/v1/rate-rules/:id

Response 204: No content


13. Alerts

GET /api/v1/alerts

Screen: S-02 | FR: FR-07

Query params:

  • country (optional, auto-applied for staff): VN|ID|MY
  • propertyId (optional)
  • alertType (optional): overbooking | sync_failure | session_expired
  • isResolved (optional): true | false
  • page, limit

Response 200:

json
{
  "data": [
    {
      "id": "string",
      "propertyId": "string",
      "propertyName": "string",
      "alertType": "string",
      "severity": "critical | warning | info",
      "message": "string",
      "isResolved": "boolean",
      "resolvedBy": "string | null",
      "resolvedAt": "string | null",
      "createdAt": "string"
    }
  ],
  "pagination": {}
}

PUT /api/v1/alerts/:id/resolve

Screen: S-02 | FR: FR-07

Request: { "notes": "string (optional)" }

Response 200: Updated alert with isResolved: true


14. Sync Jobs

GET /api/v1/sync-jobs

Screen: S-17 | FR: FR-04

Query params:

  • propertyId (optional)
  • otaType (optional)
  • jobType (optional): pull_bookings | push_availability | push_rates | verify
  • status (optional): pending | running | completed | failed
  • startDate, endDate (optional)
  • page, limit

Response 200:

json
{
  "data": [
    {
      "id": "string",
      "otaConnectionId": "string",
      "propertyName": "string",
      "otaType": "string",
      "jobType": "string",
      "status": "string",
      "error": "string | null",
      "payload": "object | null",
      "startedAt": "string | null",
      "completedAt": "string | null",
      "duration": "number | null (ms)"
    }
  ],
  "pagination": {}
}

POST /api/v1/sync-jobs/force-sync

Screen: S-02 | FR: FR-04

Force immediate sync for a property or all properties.

Request:

json
{
  "propertyId": "string | null (null = all)",
  "jobType": "pull_bookings | push_availability (required)"
}

Response 202: { "jobIds": ["string"], "message": "Sync jobs queued" }

DELETE /api/v1/sync-jobs/completed

Screen: S-17 | FR: FR-04

Clear completed jobs older than 7 days.

Response 200: { "deleted": "number" }


15. Analytics

GET /api/v1/analytics/revenue

Screen: S-14 | FR: FR-14

Query params: propertyId (optional), country (optional, auto-applied for staff), startDate, endDate, groupBy (day | week | month)

Response 200:

json
{
  "periods": [
    {
      "period": "string (date or month)",
      "totalRevenue": "number (integer)",
      "currency": "string",
      "byOta": [
        { "otaType": "string", "revenue": "number", "bookingCount": "number" }
      ]
    }
  ],
  "summary": {
    "totalRevenue": "number",
    "totalBookings": "number",
    "avgDailyRate": "number",
    "avgOccupancyPercent": "number"
  }
}

GET /api/v1/analytics/occupancy

Screen: S-14 | FR: FR-14

Query params: propertyId (optional), country (optional), startDate, endDate

Response 200:

json
{
  "dates": [
    {
      "date": "string",
      "occupancyPercent": "number",
      "totalRooms": "number",
      "bookedRooms": "number"
    }
  ]
}

16. Settings

GET /api/v1/settings

Screen: S-16 | Role: U-01

Response 200:

json
{
  "syncIntervals": {
    "bookingPullMinutes": "number (default 3)",
    "verificationMinutes": "number (default 10)",
    "sessionRefreshMinutes": "number (default 30)"
  },
  "bufferRooms": "number (default 0)",
  "notifications": {
    "lineNotifyToken": "string | null",
    "smtp": {
      "host": "string | null",
      "port": "number | null",
      "user": "string | null",
      "hasPassword": "boolean"
    },
    "alertTypes": {
      "overbooking": "boolean",
      "syncFailure": "boolean",
      "sessionExpired": "boolean"
    }
  },
  "auditLogRetentionDays": "number (default 90)"
}

PUT /api/v1/settings

Screen: S-16 | Role: U-01

Partial update of settings. Password fields only sent if changed.

POST /api/v1/settings/test-notification

Screen: S-16 | FR: FR-07

Request: { "channel": "line | email" }
Response 200: { "success": "boolean", "message": "string" }


17. Users

GET /api/v1/users

Screen: S-16 | Role: U-01

Response 200: User list (without password hashes)

GET /api/v1/users/with-org

Screen: Org Users page (/admin/organization/users) | Role: USERS.VIEW

Extended user list for the org admin directory. Includes companyId, company{ id, code, name }, and active memberships[] with department + companyTier nested selects. Used to render company/dept chips and primary indicator in one payload.

POST /api/v1/users

Screen: S-16 | Role: U-01

Request:

json
{
  "email": "string (required)",
  "name": "string (required)",
  "password": "string (required, min 8 chars)",
  "role": "manager | staff (required)",
  "country": "string | null (VN|ID|MY, required for staff)",
  "locale": "string (vi|id|ms|en, default: en)"
}

PUT /api/v1/users/:id

Update user name, roleId, country, locale, isActive, or companyId.

companyId field: only super_admin can change a user's company (cross-company reassignment). Non-super_admin receives 403 if companyId is present in the body.

DELETE /api/v1/users/:id

Deactivate user. Role: U-01

POST /api/v1/users/:id/reset-password

Screen: S-16 (admin) | FR: FR-21 | Role: USERS.EDIT permission

Admin-initiated temporary password reset. Sets temporary password and forces user to change on next login.

Request:

json
{
  "temporaryPassword": "string (required, min 8 chars)"
}

Response 200: { "message": "Temporary password set" }

Response 403: Insufficient permissions or superadmin hierarchy violation

Side effects:

  • Password hash updated to temporary password
  • mustChangePassword set to true
  • User redirected to /change-password on next login

Security: Cannot reset password for superadmin or users with higher role hierarchy

Throttle: 3 requests per 10 seconds per IP

POST /api/v1/users/:id/send-reset-link

Screen: S-16 (admin) | FR: FR-21 | Role: USERS.EDIT permission

Admin-initiated password reset via email. Generates reset token and sends email to user.

Request: Empty body

Response 200: { "message": "Reset link sent to user email" }

Response 403: Insufficient permissions or superadmin hierarchy violation

Response 429: Rate limit exceeded (3 tokens per user per hour)

Side effects:

  • Password reset token generated (1-hour expiry)
  • HTML email sent with reset link

Security: Cannot send reset link for superadmin or users with higher role hierarchy

Throttle: 3 requests per 10 seconds per IP

GET /api/v1/users/me

Screen: S-04 (profile page) | FR: FR-18

Retrieve current authenticated user's profile. Used on app mount for auth hydration.

Response 200:

json
{
  "id": "string",
  "email": "string",
  "name": "string",
  "role": "manager | staff",
  "country": "string | null",
  "locale": "string"
}

Response 401: Token expired or invalid

PATCH /api/v1/users/me

Screen: S-18 (profile page) | FR: FR-16

Update current user's profile (name, email, locale). Email must remain unique.

Request:

json
{
  "name": "string (optional)",
  "email": "string (optional, must be unique)",
  "locale": "vi | id | ms | en (optional)"
}

Response 200: Updated user object

POST /api/v1/users/me/password

Screen: S-04 (profile page), /change-password | FR: FR-17

Change current user's password. Handles both normal password change and forced password change flow.

Request:

json
{
  "currentPassword": "string (required if not mustChangePassword)",
  "newPassword": "string (required, min 8 chars)"
}

Response 200: { "message": "Password changed" }

Response 401: Current password incorrect

Side effects:

  • Password hash updated
  • mustChangePassword flag cleared (if set)
  • All refresh tokens revoked (session invalidation)

Note: If user has mustChangePassword=true, currentPassword is optional (allows reset without knowing old password).


18. Roles

GET /api/v1/roles

Screen: S-24 (Master Data) | FR: FR-20 | Role: Users VIEW

Response 200:

json
{
  "data": [
    {
      "id": "string",
      "name": "string (slug, e.g. manager)",
      "label": "string (e.g. Manager)",
      "description": "string | null",
      "permissions": "object ({ module: bitmask })",
      "isSystem": "boolean",
      "isActive": "boolean"
    }
  ]
}

GET /api/v1/roles/:id

Role: Users VIEW
Response 200: Single role object (same schema as list item)

POST /api/v1/roles

Role: Users CREATE

Request:

json
{
  "name": "string (required, unique slug: lowercase + underscores)",
  "label": "string (required)",
  "description": "string (optional)",
  "permissions": "object (required, { module: bitmask })"
}

Response 201: Created role object

PATCH /api/v1/roles/:id

Role: Users EDIT

Partial update. name cannot be changed for system roles.

Response 200: Updated role object

DELETE /api/v1/roles/:id

Role: Users DELETE

Response 204: No content
Response 400: Cannot delete system roles


19. Countries

GET /api/v1/countries [public]

No auth required. Returns all active countries.

Response 200:

json
{
  "data": [
    {
      "code": "string (VN|ID|MY)",
      "name": "string",
      "timezone": "string",
      "currency": "string",
      "bgColor": "string (hex) | null",
      "textColor": "string (hex) | null",
      "sortOrder": "number",
      "isActive": "boolean"
    }
  ]
}

POST /api/v1/countries

Role: Settings CREATE

Request:

json
{
  "code": "string (required, 2-char ISO alpha-2)",
  "name": "string (required)",
  "timezone": "string (required, IANA)",
  "currency": "string (required, ISO 4217)",
  "bgColor": "string (optional, hex)",
  "textColor": "string (optional, hex)",
  "sortOrder": "number (optional)",
  "isActive": "boolean (optional, default true)"
}

Response 201: Created country object

PATCH /api/v1/countries/:code

Role: Settings EDIT

Partial update. code (PK) cannot be changed.

Response 200: Updated country object

DELETE /api/v1/countries/:code

Role: Settings DELETE

Response 204: No content


20. Booking Status

GET /api/v1/booking-status

Returns all booking status definitions.

Query params:

  • includeDeleted (optional): true | false — include soft-deleted statuses

Response 200:

json
{
  "data": [
    {
      "key": "string",
      "label": "string",
      "color": "string (hex)",
      "icon": "string | null",
      "sortOrder": "number",
      "isDefault": "boolean",
      "isTerminal": "boolean",
      "isDeleted": "boolean",
      "statusNotes": "string | null",
      "showNotes": "boolean",
      "uiConfig": "object | null"
    }
  ]
}

GET /api/v1/booking-status/workflow

Screen: S-09, S-20 | FR: FR-26

Returns full workflow: all statuses (keyed by status key) + all active transitions. Used by frontend to render status transition buttons and validate permitted transitions.

Response 200:

json
{
  "statuses": {
    "confirmed": {
      "key": "string",
      "label": "string",
      "color": "string (hex)",
      "icon": "string | null",
      "uiConfig": "object | null",
      "isTerminal": "boolean"
    }
  },
  "transitions": [
    {
      "id": "string",
      "fromKey": "string",
      "toKey": "string",
      "allowedRoles": ["string"],
      "hooks": ["string"],
      "sortOrder": "number",
      "isActive": "boolean"
    }
  ]
}

GET /api/v1/booking-status/workflow/mermaid

Screen: S-20

Returns Mermaid stateDiagram-v2 syntax for the current workflow.

Response 200: { "diagram": "string (Mermaid stateDiagram-v2 syntax)" }

POST /api/v1/booking-status

Create a new booking status definition.

Request:

json
{
  "key": "string (required, unique slug)",
  "label": "string (required)",
  "color": "string (required, hex)",
  "icon": "string (optional)",
  "sortOrder": "number (optional, default 0)",
  "isDefault": "boolean (optional, default false)",
  "isTerminal": "boolean (optional, default false)",
  "statusNotes": "string (optional)",
  "showNotes": "boolean (optional, default false)"
}

Response 201: Created BookingStatusDef object

PATCH /api/v1/booking-status/:key

Partial update of booking status fields (label, color, icon, sortOrder, statusNotes, showNotes, isTerminal).

Response 200: Updated BookingStatusDef object

PATCH /api/v1/booking-status/:key/ui-config

Screen: S-20 | FR: FR-29 | Role: Settings EDIT

Update per-role UI configuration for a status. Controls which sections/fields are visible per role in Booking Detail.

Request: { "uiConfig": "object | null" }
Response 200: Updated BookingStatusDef object

PATCH /api/v1/booking-status/:key/delete

Soft delete a booking status (sets isDeleted: true). Bookings with this status remain unchanged.

Response 200: Updated status object

PATCH /api/v1/booking-status/:key/restore

Restore a soft-deleted booking status.

Response 200: Updated status object


20. Booking Status Transitions

GET /api/v1/booking-status-transitions

Returns all status transitions (active and inactive).

Response 200:

json
{
  "data": [
    {
      "id": "string",
      "fromKey": "string",
      "toKey": "string",
      "allowedRoles": ["string (empty = all roles allowed)"],
      "hooks": ["audit_log | update_availability | send_notification"],
      "sortOrder": "number",
      "isActive": "boolean"
    }
  ]
}

POST /api/v1/booking-status-transitions

Screen: S-20 | FR: FR-29 | Role: Settings EDIT

Request:

json
{
  "fromKey": "string (required, must exist in booking_status_def)",
  "toKey": "string (required, must exist in booking_status_def, different from fromKey)",
  "allowedRoles": ["string (optional, empty array = all roles)"],
  "hooks": ["string (optional)"],
  "sortOrder": "number (optional, default 0)",
  "isActive": "boolean (optional, default true)"
}

Response 201: Created transition object
Response 409: Transition from→to already exists

PATCH /api/v1/booking-status-transitions/:id

Role: Settings EDIT

Partial update of transition (allowedRoles, hooks, sortOrder, isActive).

Response 200: Updated transition object

DELETE /api/v1/booking-status-transitions/:id

Role: Settings EDIT

Response 204: No content


20.5 Supplier Apartments

Lark-sourced apartment hierarchy (unmapped MANUAL support). Country-scoped for staff.

GET /api/v1/supplier-apartments

Screen: S-24 | FR: FR-31 (apartments list) | Role: Suppliers VIEW

Query params:

  • af (optional): Advanced filters (URL-encoded JSON: FilterCondition[]). Supported fields: propertyId, supplierId, roomType, specialist, hasInputError, unmatchedOnly, hasNoImages, cityId, districtId, buildingName.
  • search (optional): text search in apartment code / name / address
  • propertyId (optional)
  • supplierId (optional)
  • roomType (optional)
  • specialistId (optional)
  • hasInputError (optional): 'true'|'false'
  • unmatchedOnly (optional): 'true'|'false' (show only apartments with no OTA mapping)
  • hasNoImages (optional): 'true'|'false'
  • country (optional, auto-applied for staff): VN|ID|MY
  • cityId, districtId (optional)
  • buildingName (optional)
  • page (default 1), limit (default 25)
  • sortBy (default 'createdAt'): createdAt|supplierApartmentCode|apartmentId|unitNumber

Response 200:

json
{
  "data": [
    {
      "id": "string (uuid)",
      "supplierApartmentCode": "string",
      "apartmentId": "string (Lark ID)",
      "unitNumber": "string | null",
      "addressLine1": "string | null",
      "city": "string | null",
      "district": "string | null",
      "buildingName": "string | null",
      "roomType": "string | null",
      "supplierId": "string (uuid)",
      "supplierName": "string",
      "specialistId": "string (uuid) | null",
      "specialistName": "string | null",
      "mappedPropertyId": "string (uuid) | null",
      "propertyName": "string | null",
      "hasImages": "boolean",
      "hasInputError": "boolean",
      "createdAt": "string (ISO datetime)"
    }
  ],
  "pagination": { "page": 0, "limit": 0, "total": 0, "totalPages": 0 }
}

GET /api/v1/supplier-apartments/filter-fields

Screen: (filter UI) | FR: — | Role: Suppliers VIEW

Returns metadata for advanced filter fields available on supplier-apartments list.

Response 200:

json
{
  "fields": [
    {
      "name": "supplierId",
      "type": "string",
      "label": "Supplier",
      "operators": ["is", "isNot"],
      "relatedModule": "suppliers"
    },
    {
      "name": "specialist",
      "type": "string",
      "label": "Specialist",
      "operators": ["contains", "notContains", "is", "isNot"],
      "relatedModule": "users"
    },
    {
      "name": "hasInputError",
      "type": "boolean",
      "label": "Has Input Error",
      "operators": ["is"]
    }
  ]
}

GET /api/v1/supplier-apartments/groups

Returns apartment counts grouped by property/supplier/room-type. Honors same filters as /supplier-apartments.

Response 200:

json
{
  "byProperty": [
    { "propertyId": "string", "propertyName": "string", "count": "number" }
  ],
  "bySupplier": [
    { "supplierId": "string", "supplierName": "string", "count": "number" }
  ],
  "byRoomType": [
    { "roomType": "string", "count": "number" }
  ]
}

POST /api/v1/supplier-apartments

Screen: S-24 | FR: FR-31 | Role: Suppliers CREATE

Request:

json
{
  "supplierApartmentCode": "string (required, unique within supplier)",
  "apartmentId": "string (required, Lark ID)",
  "unitNumber": "string | null",
  "addressLine1": "string | null",
  "city": "string | null",
  "district": "string | null",
  "buildingName": "string | null",
  "roomType": "string | null",
  "supplierId": "string (uuid, required)",
  "specialistId": "string (uuid) | null",
  "mappedPropertyId": "string (uuid) | null"
}

Response 201: Created apartment object

PUT /api/v1/supplier-apartments/:id

Screen: S-24 | FR: FR-31 | Role: Suppliers EDIT

Request: Same as POST (partial update supported)

Response 200: Updated apartment object

DELETE /api/v1/supplier-apartments/:id

Screen: S-24 | FR: FR-31 | Role: Suppliers DELETE

Response 204: No content

DELETE /api/v1/supplier-apartments/clear-all

Screen: S-24 | FR: FR-31 | Role: Suppliers DELETE

Deletes all apartments for the user's country scope (staff) or all countries (manager). Use with caution.

Response 204: No content


20.6 Filter Views (Advanced Filter Saved Views)

User-owned saved filter configurations, company-scoped sharing. Filters are re-validated server-side on apply.

GET /api/v1/filter-views

Query params:

  • module (optional): 'supplier-apartments'|'bookings'|... (filter by module, default returns all)

Response 200:

json
[
  {
    "id": "string (uuid)",
    "module": "string",
    "name": "string",
    "filters": [
      {
        "field": "string",
        "op": "string",
        "value": "string | number | string[]"
      }
    ],
    "isShared": "boolean",
    "createdBy": {
      "id": "string (uuid)",
      "name": "string"
    },
    "createdAt": "string (ISO datetime)",
    "updatedAt": "string (ISO datetime)"
  }
]

POST /api/v1/filter-views

Request:

json
{
  "module": "string (required: 'supplier-apartments' | 'bookings')",
  "name": "string (required, 1-255 chars)",
  "filters": [
    {
      "field": "string",
      "op": "string",
      "value": "string | number | string[]"
    }
  ],
  "isShared": "boolean (optional, default false)"
}

Response 201: Created FilterView object

Validations:

  • Filters are validated against module's FilterRegistry (apps/api/src/common/filtering/module-filter-registries.ts): field + op must match registered schema, values must fit constraints
  • Max 20 conditions per view
  • Max 50 array values per condition
  • Field operators are module-specific (e.g. supplier-apartments supports supplierId, specialist, hasInputError, unmatchedOnly, etc.)

Response 400: Invalid filters (field not in registry, operator mismatch, too many conditions, etc.)

PATCH /api/v1/filter-views/:id

Request: Partial update (name, filters, isShared)

Response 200: Updated FilterView object

Authorization: Only creator can mutate. Returns 403 if caller is not createdById, 404 if view does not exist.

DELETE /api/v1/filter-views/:id

Response 204: No content

Authorization: Only creator can delete.


21. Suppliers

GET /api/v1/suppliers

Screen: S-21 | FR: FR-31 | Role: Suppliers VIEW

Country-scoped for staff users.

Query params:

  • search (optional): name or code search (debounced)
  • status (optional): active | inactive
  • tier (optional): new — zero allocations OR created in the last 30 days (mirrors the frontend tier badge)
  • country (optional, auto-applied for staff): VN|ID|MY
  • page (default 1), limit (default 25)
  • sortBy (optional): name | supplierCode | country | createdAt (default createdAt), sortOrder: asc | desc

Response 200:

json
{
  "data": [
    {
      "id": "string",
      "supplierCode": "string",
      "name": "string",
      "country": "string (VN|ID|MY)",
      "phone": "string | null",
      "email": "string | null",
      "bankName": "string | null",
      "bankAccountNo": "string | null",
      "bankAccountName": "string | null",
      "airbnbProfileUrl": "string | null",
      "notes": "string | null",
      "isActive": "boolean",
      "totalRooms": "number (sum of active allocations)",
      "createdAt": "string (ISO datetime)"
    }
  ],
  "pagination": { "page": 0, "limit": 0, "total": 0, "totalPages": 0 }
}

GET /api/v1/suppliers/stats

Screen: S-21 | FR: FR-31

Aggregated supplier list KPI stats. Used by frontend to avoid full-data fetch.

Query params:

  • country (optional, auto-applied for staff): VN|ID|MY

Response 200:

json
{
  "total": "number (narrowed by ?country)",
  "active": "number",
  "inactive": "number",
  "rooms": "number (allocation-row count across suppliers in scope)",
  "newCount": "number ('new' tier: zero allocations OR created <30d)",
  "byCountry": { "MY": "number", "VN": "number" }
}

Note: byCountry ignores ?country (chips need all countries within the scope guard). Single country only — comma lists are not supported on stats endpoints.

GET /api/v1/suppliers/export

Role: Suppliers VIEW

Export suppliers as CSV. Country-scoped.

Response 200: Content-Type: text/csv

POST /api/v1/suppliers/import-batch

Role: Suppliers CREATE

Batch import suppliers from CSV rows. Country-scoped.

Request:

json
{
  "rows": [
    {
      "supplierCode": "string",
      "name": "string",
      "country": "string",
      "phone": "string | null",
      "email": "string | null"
    }
  ]
}

Response 200: { "created": number, "updated": number, "errors": [] }

DELETE /api/v1/suppliers/clear-all

Role: Suppliers DELETE

Delete all suppliers in the country scope. Use with caution.

Response 200: { "deleted": number }

GET /api/v1/suppliers/:id

Role: Suppliers VIEW

Response 200: Full supplier object with room allocations summary

POST /api/v1/suppliers

Screen: S-21 | FR: FR-31 | Role: Suppliers CREATE

Request:

json
{
  "supplierCode": "string (required, unique)",
  "name": "string (required)",
  "country": "string (required, VN|ID|MY)",
  "phone": "string (optional)",
  "email": "string (optional)",
  "bankName": "string (optional)",
  "bankAccountNo": "string (optional)",
  "bankAccountName": "string (optional)",
  "airbnbProfileUrl": "string (optional)",
  "notes": "string (optional)"
}

Response 201: Created supplier object

PUT /api/v1/suppliers/:id

Role: Suppliers EDIT

Full update of supplier. Partial fields supported.

Response 200: Updated supplier object

DELETE /api/v1/suppliers/:id

Role: Suppliers DELETE

Response 204: No content (hard delete, cascades to allocations)

GET /api/v1/suppliers/:id/room-allocations

Screen: S-22 | FR: FR-31 | Role: Suppliers VIEW

Response 200:

json
{
  "data": [
    {
      "id": "string",
      "roomTypeId": "string",
      "roomTypeName": "string",
      "propertyId": "string",
      "propertyName": "string",
      "roomCount": "number",
      "notes": "string | null",
      "isActive": "boolean"
    }
  ]
}

22. Supplier Room Allocations

GET /api/v1/room-types/:roomTypeId/supplier-allocations

Screen: S-23 | FR: FR-32 | Role: Suppliers VIEW

Get all supplier allocations for a specific room type.

Response 200:

json
{
  "data": [
    {
      "id": "string",
      "supplierId": "string",
      "supplierName": "string",
      "supplierCode": "string",
      "roomCount": "number",
      "notes": "string | null",
      "isActive": "boolean"
    }
  ]
}

POST /api/v1/room-types/:roomTypeId/supplier-allocations

Screen: S-23 | FR: FR-32 | Role: Suppliers CREATE

Request:

json
{
  "supplierId": "string (required)",
  "roomCount": "number (required, > 0)",
  "notes": "string (optional)"
}

Response 201: Created allocation object
Response 409: Supplier already allocated to this room type

PUT /api/v1/supplier-allocations/:id

Role: Suppliers EDIT

Request: { "roomCount": "number", "notes": "string | null", "isActive": "boolean" } (partial)
Response 200: Updated allocation object

DELETE /api/v1/supplier-allocations/:id

Role: Suppliers DELETE

Response 204: No content (soft delete: sets isActive=false)


23. Traceability Matrix

ScreenAPI EndpointsFR
S-01 LoginPOST /auth/login
S-02 DashboardGET /dashboard/summary, GET /dashboard/sync-status, GET /alerts, GET /activity-logsFR-06, FR-07, FR-08, FR-25
S-03 Properties ListGET /propertiesFR-05
S-04 Property DetailGET/PUT /properties/:id, POST/PUT/DELETE room-typesFR-05
S-05 OTA Accounts ListGET /ota-accountsFR-01
S-06 Connect OTA AccountPOST /ota-accounts, POST /ota-accounts/:id/testFR-01
S-07 Import PropertiesGET /ota-accounts/:id/discover-properties, POST /ota-accounts/:id/import-propertiesFR-02
S-08 Bookings ListGET /bookings, GET /bookings/export, GET /ota-statusFR-03, FR-41
S-09 Booking DetailGET /bookings/:id, GET /bookings/:id/history, PATCH /bookings/:id/status, PATCH /bookings/:id/revert, GET /ota-statusFR-03, FR-28, FR-41
S-10 Availability CalendarGET /availability, PUT /availability/blockFR-10
S-11 Rate ManagerGET/PUT /ratesFR-09
S-12 Booking TimelineGET /bookings (date range)FR-11
S-13 Rate ParityGET /rates/parityFR-13
S-14 AnalyticsGET /analytics/*FR-14
S-15 Rate RulesGET/POST/PUT/DELETE /rate-rulesFR-15
S-16 SettingsGET/PUT /settingsFR-08
S-17 Sync Job LogGET /sync-jobsFR-04
S-18 User ProfileGET/PATCH /users/me, POST /users/me/passwordFR-16, FR-17
S-19 Role ManagementGET/POST/PATCH/DELETE /rolesFR-20, FR-21
S-20 Workflow ConfigGET/POST/PATCH/DELETE /booking-status-transitions, PATCH /booking-status/:key/ui-configFR-25, FR-28, FR-29
S-21 Suppliers ListGET /suppliers, GET /suppliers/export, POST /suppliers/import-batchFR-31
S-22 Supplier DetailGET /suppliers/:id, GET /suppliers/:id/room-allocationsFR-31
S-23 Supplier Allocation ManagerGET/POST /room-types/:id/supplier-allocations, PUT/DELETE /supplier-allocations/:idFR-32
S-25 Customers ListGET /customers, GET /customers/suggestionsFR-33
S-26 Customer DetailGET /customers/:id, GET /customers/:id/bookings, POST /customers/:id/link, POST /customers/:id/unlinkFR-33
S-27 Customer FormPOST /customers, PATCH /customers/:idFR-33
S-28 Customer MergePOST /customers/:id/mergeFR-33
S-24 Master DataGET/POST/PATCH/DELETE /roles, GET/POST/PATCH/DELETE /countries, GET/POST/PATCH /booking-status, GET /booking-status/workflow, GET/POST/PATCH /ota-statusFR-20, FR-21, FR-22, FR-25, FR-40, FR-42

20. Customers

Customer management for guest profile tracking, booking consolidation, and CRM integration.

GET /api/v1/customers

List customers with pagination, search, and filtering.

Required Permission: CUSTOMERS:VIEW

Query Parameters:

page: number (default: 1)
limit: number (default: 25)
sortBy: 'id' | 'name' | 'email' | 'createdAt' | 'updatedAt' (default: 'createdAt')
sortOrder: 'asc' | 'desc' (default: 'desc')
search: string (fuzzy on name, email, phone)
status: 'active' | 'inactive' (optional)

Response 200:

json
{
  "data": [
    {
      "id": "uuid",
      "name": "string",
      "email": "string | null",
      "phone": "string | null",
      "nationality": "string (ISO 3166-1 alpha-2) | null",
      "notes": "string | null",
      "isActive": "boolean",
      "lastStay": "date | null",
      "_count": {
        "bookings": "integer"
      },
      "createdAt": "timestamp",
      "updatedAt": "timestamp"
    }
  ],
  "pagination": {
    "page": "integer",
    "limit": "integer",
    "total": "integer",
    "totalPages": "integer"
  }
}

GET /api/v1/customers/suggestions

Fuzzy-match unlinked bookings by name/email for quick linking.

Required Permission: CUSTOMERS:VIEW

Query Parameters:

name: string (optional)
email: string (optional)

Response 200:

json
{
  "bookings": [
    {
      "id": "uuid",
      "guestName": "string",
      "guestEmail": "string",
      "checkIn": "date",
      "checkOut": "date",
      "property": { "id": "uuid", "name": "string" }
    }
  ]
}

GET /api/v1/customers/:id

Retrieve customer detail with linked bookings (last 50).

Required Permission: CUSTOMERS:VIEW

Response 200:

json
{
  "id": "uuid",
  "name": "string",
  "email": "string | null",
  "phone": "string | null",
  "nationality": "string | null",
  "notes": "string | null",
  "isActive": "boolean",
  "_count": { "bookings": "integer" },
  "bookings": [
    {
      "id": "uuid",
      "guestName": "string",
      "guestEmail": "string",
      "otaType": "booking | trip | expedia | agoda_hotel | agoda_homes",
      "otaBookingId": "string",
      "checkIn": "date",
      "checkOut": "date",
      "status": "string",
      "totalAmount": "integer (smallest currency unit)",
      "currency": "string (ISO 4217)",
      "property": { "id": "uuid", "name": "string" },
      "roomType": { "id": "uuid", "name": "string" }
    }
  ],
  "createdAt": "timestamp",
  "updatedAt": "timestamp"
}

Response 404: { "error": "Customer not found" }

POST /api/v1/customers

Create a new customer.

Required Permission: CUSTOMERS:CREATE

Request:

json
{
  "name": "string (1-255 chars, required)",
  "email": "string (email format, optional)",
  "phone": "string (max 50 chars, optional)",
  "nationality": "string (ISO 3166-1 alpha-2, optional)",
  "notes": "string (optional)"
}

Response 201:

json
{
  "id": "uuid",
  "name": "string",
  "email": "string | null",
  "phone": "string | null",
  "nationality": "string | null",
  "notes": "string | null",
  "isActive": "boolean",
  "createdAt": "timestamp",
  "updatedAt": "timestamp"
}

Response 400: Validation error

PATCH /api/v1/customers/:id

Update customer details.

Required Permission: CUSTOMERS:EDIT

Request:

json
{
  "name": "string (optional)",
  "email": "string (optional)",
  "phone": "string (optional)",
  "nationality": "string (optional)",
  "notes": "string (optional)"
}

Response 200: Updated customer object

Response 404: { "error": "Customer not found" }

DELETE /api/v1/customers/:id

Soft-delete a customer (sets isActive = false).

Required Permission: CUSTOMERS:DELETE

Response 204: No content

Response 404: { "error": "Customer not found" }

POST /api/v1/customers/:id/link

Link booking IDs to a customer.

Required Permission: CUSTOMERS:EDIT

Request:

json
{
  "bookingIds": ["uuid", "uuid", ...]
}

Response 200:

json
{
  "message": "Linked N bookings",
  "linkedCount": "integer"
}

Response 400: Validation error (empty bookingIds)

Response 404: { "error": "Customer not found" }

POST /api/v1/customers/:id/unlink

Unlink (remove customer reference from) booking IDs.

Required Permission: CUSTOMERS:EDIT

Request:

json
{
  "bookingIds": ["uuid", "uuid", ...]
}

Response 200:

json
{
  "message": "Unlinked N bookings",
  "unlinkedCount": "integer"
}

POST /api/v1/customers/:id/merge

Merge a source customer into this target customer. All bookings from source are linked to target, then source is soft-deleted. Atomic transaction.

Required Permission: CUSTOMERS:DELETE

Request:

json
{
  "sourceCustomerId": "uuid"
}

Response 200:

json
{
  "message": "Merged X bookings",
  "mergedCount": "integer",
  "targetCustomer": { ... }
}

Response 400: { "error": "Cannot merge customer into itself" }

Response 404: { "error": "Source or target customer not found" }


24. OTA Status

OTA status normalization mappings (E-20). Maps raw OTA status strings to display labels and colors per OTA type. Requires SETTINGS permission for mutations.

GET /api/v1/ota-status

Screen: S-08, S-09, S-24 | FR: FR-40, FR-41, FR-42

Query params:

  • otaType (optional): booking | trip | expedia | agoda_hotel | agoda_homes
  • includeDeleted (optional): true | false (default false)

Response 200:

json
[
  {
    "id": "uuid",
    "rawStatus": "string",
    "otaType": "string",
    "label": "string",
    "color": "string (hex, e.g. #22C55E)",
    "sortOrder": "number",
    "isDeleted": "boolean",
    "createdAt": "string (ISO datetime)",
    "updatedAt": "string (ISO datetime)"
  }
]

POST /api/v1/ota-status

Required Permission: SETTINGS:EDIT | FR: FR-40, FR-42

Request:

json
{
  "rawStatus": "string (max 100)",
  "otaType": "booking | trip | expedia | agoda_hotel | agoda_homes",
  "label": "string (max 100)",
  "color": "string (hex, e.g. #22C55E)",
  "sortOrder": "number (optional, default 0)"
}

Response 201: Created OtaStatusDef object

Response 400: { "error": "Mapping for \"confirmed\" on booking already exists" } (duplicate rawStatus + otaType)

PATCH /api/v1/ota-status/:id

Required Permission: SETTINGS:EDIT | FR: FR-40, FR-42

Request: (all fields optional; rawStatus and otaType are immutable)

json
{
  "label": "string (optional)",
  "color": "string (optional, hex)",
  "sortOrder": "number (optional)"
}

Response 200: Updated OtaStatusDef object

Response 404: { "error": "OTA status definition not found" }

PATCH /api/v1/ota-status/:id/delete

Soft-delete a mapping (isDeleted = true). Unmapped statuses fall back to raw string display.

Required Permission: SETTINGS:EDIT | FR: FR-42

Response 200: Updated object with isDeleted: true

PATCH /api/v1/ota-status/:id/restore

Restore a soft-deleted mapping.

Required Permission: SETTINGS:EDIT | FR: FR-42

Response 200: Updated object with isDeleted: false

Note: Legacy BPM endpoints (process-types, process-status, process-instances) were planned but never implemented.
Booking workflow management uses the booking-status endpoints (§11 above).


25. Error Response Format

All error responses follow:

json
{
  "error": "string (human-readable message)",
  "code": "string (machine-readable code, e.g. VALIDATION_ERROR)",
  "details": "object | null (field-level errors)"
}

Standard HTTP Status Codes:

  • 200: Success
  • 201: Created
  • 202: Accepted (async job queued)
  • 204: No content (delete)
  • 400: Validation error
  • 401: Unauthorized
  • 403: Forbidden (role mismatch or country scope violation)
  • 404: Not found
  • 409: Conflict (duplicate)
  • 500: Internal server error

17. Organization (✅ IMPLEMENTED)

Status: Implemented in apps/api/src/modules/{companies,company-tiers,departments,department-members}/.
Plan: plans/260418-2351-im-master-org-hierarchy/phase-03-api-crud.md.
Screens: S-34, S-35 | FR: FR-59, FR-60, FR-61, FR-62, FR-63 | Entities: E-28..E-32
Auth: All endpoints require JWT. Write endpoints require admin role.

17.1 Companies (E-28)

GET /api/v1/companies

FR: FR-59 | Screen: S-34

Returns paginated companies including _count.users and _count.departments for the admin Companies list.

Response 200:

json
{
  "data": [
    { "id": "uuid", "code": "PTX", "name": "PTX", "countryCode": "VN",
      "timezone": "Asia/Ho_Chi_Minh", "isActive": true, "createdAt": "...",
      "_count": { "users": 12, "departments": 4 } }
  ],
  "pagination": { "page": 1, "limit": 25, "total": 1, "totalPages": 1 }
}

GET /api/v1/companies/:id

FR: FR-59 — returns same shape with _count.

POST /api/v1/companies

FR: FR-59 | Role: super_admin only (enforced by SuperAdminGuard)

Request: { "code": "string", "name": "string", "countryCode": "VN|ID|MY|null", "timezone": "string", "isActive": true }

PATCH /api/v1/companies/:id

Role: super_admin only.

DELETE /api/v1/companies/:id

Role: super_admin only. Soft delete (isActive=false). 409 Conflict if _count.users > 0 or _count.departments > 0 — reassign dependents first.


17.2 Company Tiers (E-29)

GET /api/v1/companies/:companyId/tiers

FR: FR-60

Response 200:

json
[
  { "id": "uuid", "code": "STAFF", "name": "Staff", "level": 1, "sortOrder": 0, "isActive": true },
  { "id": "uuid", "code": "LEAD", "name": "Team Lead", "level": 2, "sortOrder": 1, "isActive": true },
  { "id": "uuid", "code": "MANAGER", "name": "Manager", "level": 3, "sortOrder": 2, "isActive": true },
  { "id": "uuid", "code": "DIRECTOR", "name": "Director", "level": 4, "sortOrder": 3, "isActive": true }
]

POST /api/v1/companies/:companyId/tiers

Request: { "code": "string", "name": "string", "level": 1, "sortOrder": 0 }
Errors: 409 if (companyId, code) or (companyId, level) already exists.

PATCH /api/v1/tiers/:id

DELETE /api/v1/tiers/:id

Errors: 409 if tier referenced by active department_roles or department_members.


17.3 Departments (E-30)

GET /api/v1/companies/:companyId/departments

FR: FR-61

Query: function, isActive, parentId, search

Response 200 (flat):

json
[
  { "id": "uuid", "code": "CS", "name": "Customer Support",
    "function": "customer_support", "parentId": null,
    "managerUserId": null, "memberCount": 3, "isActive": true }
]

GET /api/v1/companies/:companyId/departments/tree

FR: FR-61 | Screen: S-34

Response 200 (nested):

json
[
  {
    "id": "uuid", "code": "CS", "name": "Customer Support",
    "function": "customer_support", "children": [
      { "id": "uuid", "code": "CS-VN", "name": "CS Vietnam", "children": [] }
    ]
  }
]

GET /api/v1/departments/:id

Returns dept with roles[] (tier+title) and member count.

POST /api/v1/companies/:companyId/departments

Request:

json
{
  "code": "CS",
  "name": "Customer Support",
  "function": "customer_support",
  "parentId": null,
  "managerUserId": null,
  "sortOrder": 0
}

PATCH /api/v1/departments/:id

Errors: 400 if parentId update creates cycle.

DELETE /api/v1/departments/:id

Errors: 409 if department has active members.

POST /api/v1/departments/:id/roles

FR: FR-62

Request: { "companyTierId": "uuid", "title": "Customer Support Manager" }
Errors: 400 if tier's companyId ≠ dept's companyId.

PATCH /api/v1/department-roles/:id

Update title only.

DELETE /api/v1/department-roles/:id

Errors: 409 if role has active members.


17.4 Department Members (E-32)

GET /api/v1/departments/:id/members

FR: FR-63 | Screen: S-34

Response 200:

json
[
  {
    "id": "uuid",
    "user": { "id": "uuid", "name": "...", "email": "..." },
    "departmentRole": { "id": "uuid", "title": "CS Staff", "tier": { "code": "STAFF", "level": 1 } },
    "isPrimary": true,
    "isActive": true,
    "assignedAt": "..."
  }
]

GET /api/v1/users/:userId/memberships

FR: FR-63

POST /api/v1/department-members

FR: FR-63 | Screen: S-35

Request:

json
{
  "userId": "uuid",
  "departmentId": "uuid",
  "departmentRoleId": "uuid",
  "isPrimary": false
}

Errors:

  • 400 if role doesn't belong to specified department, or tier mismatch
  • 409 if (userId, departmentId) already exists

PATCH /api/v1/department-members/:id

Change role/tier within same department.

POST /api/v1/department-members/:id/set-primary

Atomically un-flag previous primary, flag this one. Idempotent.

DELETE /api/v1/department-members/:id

Remove assignment.


17.5 Error Codes (Org-specific)

CodeHTTPMeaning
ORG_DEPT_CYCLE400parentId update would create cycle
ORG_TIER_COMPANY_MISMATCH400Tier and Department belong to different Companies
ORG_ROLE_TIER_MISMATCH400DepartmentMember's companyTierId ≠ role's companyTierId
ORG_DEPT_HAS_MEMBERS409Cannot delete Department with active members
ORG_ROLE_HAS_MEMBERS409Cannot delete DepartmentRole with active members
ORG_MEMBER_EXISTS409User already a member of this Department

18. Threads (Lead/Manager Layer)

Permission identity is derived from DepartmentMember.companyTier.code (STAFF | LEAD | MANAGER | DIRECTOR). DIRECTOR auto-inherits MANAGER privileges. Server-side authorization via lead-helpers is the source of truth; UI uses GET /users/me/dept-tiers for visibility only.

All privileged write endpoints below produce an AuditLog row automatically (via prisma-audit extension on Conversation, Participant).

Removed (2026-05): The v3.0.0 risk-flag endpoints (GET /threads/risk, GET/POST/PATCH /risks/:bookingId) and POST /threads/rescue were removed along with the booking_risk table. The threads console is now a booking-thread inbox without risk flagging.

GET /api/v1/users/me/dept-tiers

Returns { [DepartmentFunction]: TierCode } for the caller, omitting departments the caller is not in. Used by frontend useDeptTier hook.

GET /api/v1/threads/list

Cursor-paginated booking-thread feed for the caller (replaces former /api/v1/chat/war-room/threads). Filters: active, has_messages, unread_mine, assigned_mine. Auth scope: caller must be a Participant.

GET /api/v1/threads/unread-count

Single integer count of booking-linked conversations the caller has not fully read (sidebar badge). Replaces former /api/v1/chat/war-room/unread-count.

GET /api/v1/threads/managed?fn=customer_support

LEAD+ tier in the requested function. Returns conversations whose track is currently active for that function:

  • customer_supportbooking.status in active CS workflow (BookingStatusDef.isTerminal=false, department='cs')
  • purchasingbooking.sourceStatus active
  • financebooking.accountingStatus active
  • ground_opsbooking.sourceStatus = 'src_confirmed' AND checkinTime IS NULL
  • ota → has Participant from ota-function dept
  • operations → no stage filter; LEAD+ sees every booking-linked thread

Returns: { items: ManagedThread[], total, page, limit }. 403 if caller is not LEAD+ in fn.

PATCH /api/v1/threads/:id/assignee

Body: { userId: UUID }. LEAD+ in scope. Demotes existing ADMIN participants to MEMBER, upserts target user as ADMIN. Returns the updated conversation with participants.

POST /api/v1/threads/:id/participants/backup

Body: { userId: UUID }. LEAD+ in scope. Idempotent — adds target user as MEMBER if not already a participant. Returns { ok: true }.

Audit Log Coverage

Every privileged write produces an AuditLog row:

  • entityType: conversation | participant
  • action: create | update
  • oldValue, newValue: JSON snapshots before/after
  • performedBy: caller's userId

18.1 Error Codes (Lead/Manager Layer)

CodeHTTPMeaning
FORBIDDEN403Caller lacks required tier (LEAD+ or MANAGER+ depending on action)
BAD_REQUEST400Booking not in cancelled state, or no contact info, or invalid input
NOT_FOUND404Booking or conversation does not exist

PTX Channel Manager — Internal Documentation