Skip to content

Đặc Tả Giao Diện Lập Trình (API_SPEC)

Dự ÁnPTX Channel Manager (ptx-cm)
Phiên Bản2.1.0
Ngày2026-02-20
LoạiREST API (JSON)
Base URL/api/v1

1. Xác Thực

Tất cả endpoint yêu cầu JWT Bearer token trừ khi được đánh dấu [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]

RequestEmpty body. Token passed via refresh_token HttpOnly cookie.
Response 200{ } (new tokens set in cookies)
Rate limit10/min per IP
  • 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

RequestEmpty body. Token passed via refresh_token HttpOnly cookie.
Response 204No content
Side effectsRefresh 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 Bảo Mật & Kiểm Soát Truy Cập

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. Bảng Điều Khiển

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 a rotating text file (logs/activity.log) using efficient tail-reading (64KB buffer from end).

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 logs all HTTP requests to file
  • Format: TIMESTAMP | EMAIL | METHOD | PATH | STATUS | SCREEN
  • Excludes activity-logs endpoint itself from logging (prevents recursion)
  • Controlled by ACTIVITY_LOG_ENABLED environment variable (default: true)
  • Log file path: logs/activity.log (added to .gitignore)
  • Efficient tail-reading: Reads 64KB buffer from file end, parses backward to extract last N entries

3. Cơ Sở Lưu Trú (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

RequestSame fields as POST (partial update supported)
Response 200Updated 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. Loại Phòng (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

RequestPartial update of room type fields
Response 200Updated room type

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

Response 204: No content (soft delete)


5. Tài Khoản OTA

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.

RequestPartial update (same fields as POST, all optional)
Response 200Updated 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. Kết Nối OTA

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. Mapping Phòng OTA

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. Đặt Phòng (Bookings)

GET /api/v1/bookings

Screen: S-08 | FR: FR-03

Query params:

  • country (optional, auto-applied for staff): VN|ID|MY
  • propertyId (optional)
  • otaType (optional, comma-separated)
  • status (optional): confirmed | cancelled | no_show
  • 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",
      "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

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",
  "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 200Updated booking object
Response 400Invalid transition or target status not found
Response 403User'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 400No 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 200Updated availability object
Side effectQueues 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"
    }
  ]
}

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

RoleUsers VIEW
Response 200Single 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 204No content
Response 400Cannot 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
Response 200Updated 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 201Created transition object
Response 409Transition 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 201Created allocation object
Response 409Supplier already allocated to this room type

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

Role: Suppliers EDIT

Request`{ "roomCount": "number", "notes": "string
Response 200Updated 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/exportFR-03
S-09 Booking DetailGET /bookings/:id, GET /bookings/:id/history, PATCH /bookings/:id/status, PATCH /bookings/:id/revertFR-03, FR-28
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-24 Master DataGET/POST/PATCH/DELETE /roles, GET/POST/PATCH/DELETE /countries, GET/POST/PATCH /booking-status, GET /booking-status/workflowFR-20, FR-21, FR-22, FR-25

24. 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 — Tài Liệu Nội Bộ