Skip to content

Interface Specification (API_SPEC)

Project: PTX Channel Manager (ptx-cm)
Version: 2.6.0
Date: 2026-03-30
Type: REST API (JSON)
Base URL: /api/v1


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

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 | agoda | traveloka | expedia",
      "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

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/:id

Screen: S-04 | FR: FR-05

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 | agoda | traveloka | expedia",
      "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 | agoda | traveloka | expedia (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)"
}

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

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

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

Screen: S-04 | FR: FR-02

Response 204: No content


7. 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. 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
  • 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)"
    }
  ],
  "pagination": { "page": 0, "limit": 0, "total": 0, "totalPages": 0 }
}

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/export

Screen: S-08 | FR: FR-03

Same filters as GET /bookings. 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


9. 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


10. 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)" }

11. 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


12. 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


13. 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" }


14. 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"
    }
  ]
}

15. 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" }


16. Users

GET /api/v1/users

Screen: S-16 | Role: U-01

Response 200: User list (without password hashes)

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 role, name, country, or locale.

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).


17. 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


18. 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


19. 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


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
  • country (optional, auto-applied for staff): VN|ID|MY
  • page (default 1), limit (default 25)

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/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 | agoda | traveloka | expedia",
      "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 | agoda | traveloka | expedia
  • 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 | agoda | traveloka | expedia",
  "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


25. Process Types (Phase B)

GET /api/v1/process-types

Screen: S-28 | FR: FR-44, FR-45 | Required Permission: None (all roles)

Returns all process type definitions, optionally filtered.

Query params:

  • isActive (optional): true | false — filter active/inactive

Response 200:

json
{
  "data": [
    {
      "id": "uuid",
      "key": "string (e.g., 'booking', 'ota_sync')",
      "label": "string",
      "description": "string | null",
      "entityClass": "string",
      "isActive": "boolean",
      "createdAt": "string (ISO datetime)"
    }
  ]
}

GET /api/v1/process-types/:key

Required Permission: Settings VIEW (admin)

Returns a single process type definition.

Response 200: Single ProcessType object
Response 404: { "error": "Process type not found" }

POST /api/v1/process-types

Required Permission: Settings EDIT (admin) | FR: FR-45

Create a custom process type (seeded types are immutable).

Request:

json
{
  "key": "string (required, unique, slug format)",
  "label": "string (required)",
  "description": "string (optional)",
  "entityClass": "string (required, e.g., 'custom_process')",
  "isActive": "boolean (optional, default true)"
}

Response 201: Created ProcessType object
Response 409: { "error": "Process type with key 'X' already exists" }

PATCH /api/v1/process-types/:id

Required Permission: Settings EDIT (admin)

Update a custom process type (seeded types: key/entityClass immutable, but label/description/isActive editable).

Response 200: Updated ProcessType object

DELETE /api/v1/process-types/:id

Required Permission: Settings DELETE (admin)

Response 204: No content


26. Process Status Definitions (Phase B)

GET /api/v1/process-status

Screen: S-28 | FR: FR-44, FR-45 | Required Permission: None (all roles)

Returns status definitions for a given process type.

Query params:

  • processType (required): string (e.g., 'booking', 'ota_sync')
  • includeDeleted (optional): true | false

Response 200:

json
{
  "data": [
    {
      "key": "string",
      "processTypeKey": "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",
      "createdAt": "string (ISO datetime)",
      "updatedAt": "string (ISO datetime)"
    }
  ]
}

GET /api/v1/process-status/workflow

Screen: S-28 | FR: FR-44

Returns full workflow for a process type: statuses + active transitions.

Query params:

  • processType (required): string

Response 200:

json
{
  "statuses": {
    "confirmed": { "key": "string", "label": "string", ... }
  },
  "transitions": [
    { "id": "uuid", "fromKey": "string", "toKey": "string", ... }
  ]
}

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

Screen: S-28

Returns Mermaid stateDiagram-v2 syntax for a process workflow.

Query params:

  • processType (required): string

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

POST /api/v1/process-status

Required Permission: Settings EDIT (admin) | FR: FR-45

Create a new status definition for a process type.

Request:

json
{
  "key": "string (required, unique per process type)",
  "processTypeKey": "string (required)",
  "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 status object

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

Required Permission: Settings EDIT (admin)

Partial update of status definition.

Request: (all fields optional)

json
{
  "label": "string",
  "color": "string (hex)",
  "icon": "string",
  "sortOrder": "number",
  "statusNotes": "string",
  "showNotes": "boolean",
  "isTerminal": "boolean"
}

Response 200: Updated status object

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

Required Permission: Settings EDIT (admin)

Update per-role UI configuration.

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

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

Soft delete a status definition.

Required Permission: Settings EDIT (admin)

Response 200: Updated status with isDeleted: true

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

Restore a soft-deleted status definition.

Required Permission: Settings EDIT (admin)

Response 200: Updated status with isDeleted: false


27. Process Transitions (Phase B)

GET /api/v1/process-transitions

Screen: S-28 | FR: FR-44, FR-45 | Required Permission: None (all roles)

Returns transitions for a given process type.

Query params:

  • processType (required): string

Response 200:

json
{
  "data": [
    {
      "id": "uuid",
      "fromKey": "string",
      "toKey": "string",
      "processTypeKey": "string",
      "allowedRoles": ["string"],
      "hooks": ["string"],
      "sortOrder": "number",
      "isActive": "boolean",
      "createdAt": "string (ISO datetime)"
    }
  ]
}

POST /api/v1/process-transitions

Required Permission: Settings EDIT (admin) | FR: FR-45

Create a new transition for a process type.

Request:

json
{
  "fromKey": "string (required)",
  "toKey": "string (required, different from fromKey)",
  "processTypeKey": "string (required)",
  "allowedRoles": ["string (optional, empty = all)"],
  "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 for process type

PATCH /api/v1/process-transitions/:id

Required Permission: Settings EDIT (admin)

Partial update of transition.

Request: (all fields optional)

json
{
  "allowedRoles": ["string"],
  "hooks": ["string"],
  "sortOrder": "number",
  "isActive": "boolean"
}

Response 200: Updated transition object

DELETE /api/v1/process-transitions/:id

Required Permission: Settings EDIT (admin)

Response 204: No content


28. Process Instances (Phase B)

GET /api/v1/process-instances

Screen: S-29 | FR: FR-46 | Required Permission: Settings VIEW

Returns process instances with optional filtering and pagination.

Query params:

  • processType (optional): string — filter by process type
  • isActive (optional): true | false — filter active/completed
  • entityId (optional): string — filter by entity ID
  • page (optional): number (default 1)
  • limit (optional): number (default 20)

Response 200:

json
{
  "data": [
    {
      "id": "uuid",
      "processTypeKey": "string",
      "entityId": "string",
      "currentStatusKey": "string",
      "startedBy": { "id": "uuid", "email": "string", "name": "string" } | null,
      "startedAt": "string (ISO datetime)",
      "completedAt": "string (ISO datetime) | null",
      "isActive": "boolean"
    }
  ],
  "pagination": {
    "page": "number",
    "limit": "number",
    "total": "number"
  }
}

POST /api/v1/process-instances

Required Permission: Settings EDIT (admin) | FR: FR-46

Create a new process instance (typically done by system; manual creation for admin override).

Request:

json
{
  "processTypeKey": "string (required)",
  "entityId": "string (required)",
  "initialStatusKey": "string (required, must exist for process type)"
}

Response 201: Created ProcessInstance object

PATCH /api/v1/process-instances/:id/transition

Required Permission: Settings EDIT | FR: FR-46

Transition a process instance to a new status.

Request:

json
{
  "toStatusKey": "string (required)",
  "note": "string (optional, audit log)"
}

Response 200: Updated ProcessInstance object
Response 400: Invalid transition / status not allowed

GET /api/v1/process-instances/summary

Screen: S-02 Dashboard | FR: FR-46 | Required Permission: Settings VIEW

Returns count of active process instances by process type.

Response 200:

json
{
  "data": {
    "booking": 42,
    "ota_sync": 0,
    "alert_resolution": 3,
    "customer_mgmt": 0,
    "ota_onboarding": 1,
    "property_setup": 0
  }
}

GET /api/v1/process-instances/by-entity

Screen: S-09, S-04, S-27 | FR: FR-46 | Required Permission: Settings VIEW

Returns process instances for a specific entity (polymorphic query).

Query params:

  • processType (required): string
  • entityId (required): string

Response 200:

json
{
  "data": [
    { "id": "uuid", "processTypeKey": "string", "entityId": "string", ... }
  ]
}

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

PTX Channel Manager — Internal Documentation