Interface Specification (API_SPEC)
Project: PTX Channel Manager (ptx-cm)
Version: 3.0.5
Date: 2026-06-05
Type: REST API (JSON)
Base URL: /api/v1
v3.0.5 Updates (2026-06-05):
- Added
POST /ota/trip/reservationsendpoint (Trip.com inbound reservation push via OpenTravel XML)
v3.0.4 Updates (2026-06-05):
- Entity internal code module implemented: all detail endpoints now accept internal code (preferred) OR UUID
- Added
GET /ota-listings/facetsendpoint (lightweight filter options) - Added
/ota-status/*endpoints (CRUD + soft-delete/restore for OTA status mappings)
v3.0.3 Updates (2026-06-04):
- Added
/supplier-apartments/*endpoints (list, filter-fields, groups, CRUD, clear-all) - Added
/filter-viewsCRUD endpoints (saved filter views)
v3.0.2 Updates (2026-06-04):
- Added
/ota-listings/*CRUD endpoints (create, read, update, delete) - Added
GET /properties/statsandGET /properties/facetsendpoints (lightweight KPI/filter data) - Added
GET /ota-connections/statsendpoint
v3.0.1 Updates:
- Added
/bookings/filter-fieldsendpoint (advanced filter field metadata) - Added
af(advanced filters) param toGET /bookings
1. Authentication
All endpoints require JWT Bearer token unless marked [public].
POST /api/v1/auth/login [public]
Screen: S-01 | FR: —
Login with email/password. Returns JWT access token + refresh token.
Request:
{
"email": "string (required)",
"password": "string (required)"
}Response 200:
{
"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:
{
"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:
{
"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:
{
"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 != nullin JWT): Auto-scoped to their assigned country. All queries automatically filtered. - Manager users (
country == nullin JWT): Can view all countries. Optional?country=VN|ID|MYquery parameter narrows scope. - Allowed countries: TH, VN, ID (validated in CountryScopeGuard)
- Scope violation: Returns 403 Forbidden if accessing cross-country property/booking
Rate Limiting
| Endpoint | Limit | Window |
|---|---|---|
POST /auth/login | 5 requests | 60 seconds |
POST /auth/refresh | 10 requests | 60 seconds |
| Global (all other) | 10 requests | 60 seconds |
Role-Based Access
| Operation | Role Required | Notes |
|---|---|---|
| Create property | manager | Staff cannot create properties |
| Create OTA account | manager | Staff cannot add OTA credentials |
| Update settings | manager | Global config restricted |
| Delete user | manager | Account deactivation only |
| List/filter own data | any | All lists country-scoped |
Entity Internal Codes
All detail endpoints (:id route parameters) accept internal codes (preferred) OR UUID (legacy for backward compatibility). Internal codes are human-readable identifiers auto-generated at entity creation:
| Entity | Code Format | Example |
|---|---|---|
| Property | 2-letter country code prefix + 6–8 hex | MY-22846F |
| Customer | CU- + 6–8 hex | CU-A3F92B |
| Booking | BK- + 6–8 hex | BK-7D21C4 |
| Supplier Apartment | SA- + 6–8 hex | SA-1B0C44 |
| OTA Listing Detail | LD- + 6–8 hex | LD-F4A8C1 |
| Supplier | Business supplierCode (no fixed shape) | HCM001 |
| Department | Department.code (resolved company-scoped) | CS |
Codes follow XX-HHHHHH (6 hex default, extended to 8 hex on collision) per ENTITY_CODE_PREFIXES in packages/types/src/entity-codes.ts. Supplier and Department are resolvable via their existing business codes but have no stored internalCode column.
Usage:
- Route parameters: Accept either:
GET /properties/MY-22846ForGET /properties/{uuid} - Resolution: Server-side
EntityCodeServicetranslates code → UUID before querying - Request bodies/queries: Always carry real UUID from fetched entity; never send internal codes in request payloads
Implementation reference: See apps/api/src/common/entity-code/entity-code.service.ts and apps/web/lib/routes.ts (client-side route builders).
2. Dashboard
GET /api/v1/dashboard/summary
Screen: S-02 | FR: FR-06
Returns KPI cards and sync overview. Filtered by user's country if scoped.
Query params:
country(optional, managers only): VN|ID|MY — filter by country
Response 200:
{
"kpi": {
"propertiesOnline": "number",
"propertiesTotal": "number",
"todayBookings": "number",
"syncHealthPercent": "number (0-100)",
"activeAlerts": "number"
},
"recentBookings": [
{
"id": "string",
"propertyName": "string",
"guestName": "string",
"otaType": "booking | trip | expedia | agoda_hotel | agoda_homes",
"checkIn": "string (ISO date)",
"checkOut": "string (ISO date)",
"totalAmount": "number (integer, smallest currency unit)",
"currency": "string (VND|IDR|MYR|USD)",
"status": "confirmed | cancelled | no_show",
"createdAt": "string (ISO datetime)"
}
],
"syncStatus": [
{
"propertyId": "string",
"propertyName": "string",
"connections": [
{
"otaType": "string",
"status": "active | expired | error | requires_2fa",
"lastSyncAt": "string (ISO datetime) | null",
"pendingJobs": "number"
}
]
}
]
}GET /api/v1/dashboard/sync-status
Screen: S-02 (sidebar) | FR: FR-06
Lightweight endpoint for sidebar polling (every 10s). Country-scoped.
Query params:
country(optional, managers only): VN|ID|MY
Response 200:
{
"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:
{
"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-03Implementation Details:
- Middleware
activity-log.middleware.tsrecords all HTTP requests to theactivity_logstable 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_ENABLEDenvironment variable (default: true) - Uses
createManyfor batched client events
POST /api/v1/activity-logs/events ✅
Screen: (client-side) | FR: FR-35
Client-side batched event submission. Used by frontend to log UI events/screen views. Authenticated.
Request:
{
"events": [
{ "method": "CLIENT", "path": "/properties", "status": 0, "screen": "S-03" }
]
}Response 201: { "accepted": <count> }
Implementation: apps/api/src/modules/activity-logs/activity-logs.controller.ts:59
3. Properties
GET /api/v1/properties
Screen: S-03 | FR: FR-05
Query params:
country(optional, managers only): VN|ID|MY — manager filter; staff auto-scopedstatus(optional):active | inactivesearch(optional): Search by namepage(default 1): Page numberlimit(default 25): Items per pagesortBy(defaultname): Field to sort by — whitelisted to prevent injectionsortOrder(defaultasc):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:
{
"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:
{
"name": "string (required)",
"country": "string (required, VN|ID|MY)",
"timezone": "string (required)",
"currency": "string (required, VND|IDR|MYR)",
"address": "string (optional)"
}Response 201: Property object
GET /api/v1/properties/stats
Screen: S-03 | FR: FR-05
Aggregated KPI stats for property list KPI cards. Used by frontend list pages to avoid full-data fetch.
Query params:
country(optional, managers only): VN|ID|MY — filter by country
Response 200:
{
"total": "number (narrowed by ?country)",
"byCountry": { "MY": "number", "VN": "number" }
}Note: Response ~100B vs 2–3.5MB for full paginated list. byCountry ignores ?country (filter chips need all countries within the scope guard). Single country only.
GET /api/v1/properties/facets
Screen: S-03 | FR: FR-05
Distinct filter options for list filter dropdowns. Payload ~1KB.
Query params: none (country scope guard auto-applies)
Response 200:
{
"cities": ["Hanoi", "Kuala Lumpur"],
"countries": ["MY", "VN"]
}GET /api/v1/properties/base-rate-matrix ✅
Screen: S-11 | Role: PROPERTIES.VIEW
Returns property × room-type × OTA base-rate matrix (country-scoped). Used by bulk rate editor.
Response 200: Nested matrix grouped by property → room types → OTA connections with current base rate.
Implementation: apps/api/src/modules/properties/properties.controller.ts:30
GET /api/v1/properties/:id
Screen: S-04 | FR: FR-05
Route Parameter :id:
- Accepts internal code (preferred, e.g.
MY-22846F) OR UUID (legacy, always works for backward compatibility) - Resolved by
EntityCodeServiceto the property's real UUID before querying
Response 200: Full property object with room types and OTA connections
{
"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:
{
"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|MYstatus(optional): active | expired | error | requires_2fa
Response 200:
{
"data": [
{
"id": "string",
"otaType": "booking | trip | expedia | agoda_hotel | agoda_homes",
"label": "string",
"status": "active | expired | error | requires_2fa",
"twoFactorMethod": "none | totp | manual",
"lastSessionRefresh": "string (ISO datetime) | null",
"countryCode": "string (VN|ID|MY) | null",
"propertyCount": "number",
"createdAt": "string (ISO datetime)"
}
]
}POST /api/v1/ota-accounts
Screen: S-06 | FR: FR-01
Create new OTA account with credentials.
Request:
{
"otaType": "booking | trip | expedia | agoda_hotel | agoda_homes (required)",
"label": "string (required)",
"credentials": {
"username": "string (required)",
"password": "string (required)"
},
"twoFactorMethod": "none | totp | manual (required)",
"totpSecret": "string (optional, required if twoFactorMethod=totp)",
"countryCode": "string (optional, VN|ID|MY). OTA×country matrix validation enforced when COUNTRY_SCOPE_ENABLED."
}Response 201: OtaAccount object (credentials excluded)
GET /api/v1/ota-accounts/:id
Screen: S-05 | FR: FR-01
Response 200:
{
"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:
{
"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:
{
"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:
{
"properties": [
{
"otaPropertyId": "string (required)",
"existingPropertyId": "string | null",
"action": "create_new | link_existing"
}
]
}Response 200:
{
"imported": [
{
"propertyId": "string",
"propertyName": "string",
"connectionId": "string",
"roomTypesCreated": "number",
"mappingsCreated": "number"
}
],
"errors": [
{
"otaPropertyId": "string",
"error": "string"
}
]
}6. OTA Connections
GET /api/v1/ota-connections
Screen: S-15 (OTA Properties) | FR: —
List OTA connections (Lark OTA listings) with pagination, search, and filtering. Country-scoped.
Query params:
country(optional, auto-applied for staff): VN|ID|MYchannel(optional): OTA platform filtercity(optional): City filter (cascading)ptxSync(optional): 'DONE' | '' (mapped vs unmapped)search(optional): Search by name, property ID, etc.page(default 1),limit(default 20)sortBy(optional): whitelisted —building|city|otaStatus|createdAt(relation sorts resolved server-side; omitted →createdAt desc)sortOrder(optional, defaultascwhen sortBy given): 'asc' | 'desc'
Response 200:
{
"data": [
{
"id": "uuid",
"otaPropertyId": "string",
"otaPropertyName": "string",
"propertyId": "uuid | null (null if unmapped)",
"otaAccountId": "uuid",
"channel": "string",
"city": "string",
"ptxSync": "DONE | '' (empty)",
"otaStatus": "string (Lark enum value)",
"createdAt": "timestamp"
}
],
"pagination": { "page": 0, "limit": 0, "total": 0, "totalPages": 0 }
}GET /api/v1/ota-connections/stats
Aggregated OTA connections KPI stats for list page.
Query params:
country(optional, auto-applied for staff): VN|ID|MY
Response 200:
{
"total": "number (narrowed by ?country)",
"synced": "number (ptxSync = DONE)",
"activeOnOta": "number (otaStatus starts with '2.')",
"unmapped": "number (propertyId = null)",
"byCountry": { "MY": "number", "SG": "number" }
}A connection's country = COALESCE(property.country, otaAccount.countryCode). byCountry ignores ?country (chips need all countries within the scope guard); sum(byCountry) may be < total when both sources are null. The city/country filter dropdowns on this page use GET /properties/facets (there is no ota-connections facets endpoint).
POST /api/v1/ota-connections
Screen: S-04, S-07 | FR: FR-02
Create lightweight link between property and OTA account.
Request:
{
"propertyId": "string (required)",
"otaAccountId": "string (required)",
"otaPropertyId": "string (required)"
}Response 201: OtaConnection object
PATCH /api/v1/ota-connections/:id
Screen: S-15 | FR: —
Update OTA listing metadata (Lark-sourced fields). Save-on-blur per field. Country-scoped.
Request (partial update):
{
"otaPropertyName": "string (MaxLength 255)",
"ptxSync": "DONE | ''",
"bank": "string (optional)",
"reviewScore": "number (optional)",
"otaStatus": "string (enum value from Lark)"
}Response 200: Updated OtaConnection object
Note: Only changed fields are included in request body. Backend returns 400 if unknown fields included or validation fails (e.g., otaStatus not in whitelist).
PUT /api/v1/ota-connections/:id/map
Screen: S-15 | FR: —
Link/unlink a building to/from an OTA listing. Country-scoped.
Request (link):
{
"propertyId": "uuid (required)"
}Request (unlink):
{
"propertyId": null
}Response 200: Updated OtaConnection object
DELETE /api/v1/ota-connections/:id
Screen: S-04 | FR: FR-02
Response 204: No content
7. OTA Listings (Source List)
GET /api/v1/ota-listings
Screen: S-19 (Source Listings) | FR: —
List OTA listings (Source List) imported from Lark with pagination, search, and filtering. Country-scoped.
Query params:
page(default 1),limit(default 20)search(optional): Search by name or internal codepropertyId(optional): Filter by property UUIDcurrencyName(optional): Filter by currency (e.g., VND, IDR, MYR)status(optional, defaultactive):active|inactive|all— soft-delete visibilitysortBy(optional, defaultupdatedAt): Whitelisted fields —name|internalCode|costWd|costWk|maxPax|currencyName|updatedAt|createdAtsortOrder(optional, defaultasc):asc|desc
Response 200:
{
"data": [
{
"id": "uuid",
"internalCode": "string (LD-xxxxxx)",
"name": "string",
"description": "string | null",
"propertyId": "uuid | null",
"roomTypeId": "uuid",
"costWd": "decimal (weekday cost)",
"costWk": "decimal (weekend cost)",
"currencyName": "string (VND|IDR|MYR)",
"currencyRate": "decimal",
"marginLowSs": "decimal (fraction 0–1)",
"marginHighSs": "decimal (fraction 0–1)",
"marginHoli": "decimal (fraction 0–1)",
"levelSource": "decimal | null",
"checkInFile": "string | null",
"restrictionRules": "string | null",
"checkInWay": "string | null",
"checkInOutPolicy": "string | null",
"childPolicy": "string | null",
"cleaningFee": "decimal | null",
"roomSize": "decimal | null",
"bedType": "string | null",
"bathrooms": "decimal | null",
"maxPax": "integer | null",
"roomPhotos": "string[] | null",
"amenities": "object (key-value flags)",
"isActive": "boolean (soft-delete state)",
"createdAt": "timestamp",
"updatedAt": "timestamp"
}
],
"pagination": { "page": 0, "limit": 0, "total": 0, "totalPages": 0 }
}GET /api/v1/ota-listings/facets
Screen: S-19 (Source Listings) | FR: —
Aggregated facets for list page filters.
Response 200:
{
"currencies": ["VND", "IDR", "MYR"],
"statuses": [
{ "value": "active", "label": "Active", "count": "number" },
{ "value": "inactive", "label": "Inactive (Deleted)", "count": "number" }
]
}GET /api/v1/ota-listings/:id
Screen: S-19-Detail (Source Listing Detail) | FR: —
Fetch a single OTA listing by UUID or internal code. Route parameter accepts both.
Response 200: OtaListingDetail object (see GET /api/v1/ota-listings for schema)
POST /api/v1/ota-listings
Role: OTA_CONNECTIONS CREATE
Create a new OTA listing manually.
Request:
{
"name": "string (required, max 255)",
"propertyId": "uuid (required)",
"roomTypeId": "uuid (required)",
"costWd": "number (required, ≥ 0)",
"costWk": "number (required, ≥ 0)",
"currencyName": "string (required, e.g., VND)",
"currencyRate": "number (required, ≥ 0)",
"marginLowSs": "number (required, 0–1, stored as fraction)",
"marginHighSs": "number (required, 0–1, stored as fraction)",
"marginHoli": "number (required, 0–1, stored as fraction)",
"description": "string (optional)",
"levelSource": "number (optional)",
"checkInFile": "string (optional)",
"restrictionRules": "string (optional)",
"checkInWay": "string (optional)",
"checkInOutPolicy": "string (optional)",
"childPolicy": "string (optional)",
"cleaningFee": "number (optional, ≥ 0)",
"roomSize": "number (optional, ≥ 0)",
"bedType": "string (optional)",
"bathrooms": "number (optional, ≥ 0)",
"maxPax": "integer (optional, ≥ 0)",
"roomPhotos": "string[] (optional)",
"amenities": "object (optional, key-value)"
}Response 201: Created OtaListingDetail object with auto-generated internalCode (LD prefix)
Note: internalCode is auto-generated unique identifier. propertyId and roomTypeId must exist.
PATCH /api/v1/ota-listings/:id
Role: OTA_CONNECTIONS EDIT
Partial update of OTA listing. Save-on-blur per field supported. Restore soft-deleted listing via { "isActive": true }.
Request (all fields optional):
{
"name": "string (max 255)",
"propertyId": "uuid",
"roomTypeId": "uuid",
"costWd": "number (≥ 0)",
"costWk": "number (≥ 0)",
"currencyName": "string",
"currencyRate": "number (≥ 0)",
"marginLowSs": "number (0–1)",
"marginHighSs": "number (0–1)",
"marginHoli": "number (0–1)",
"description": "string | null",
"levelSource": "number | null",
"checkInFile": "string | null",
"restrictionRules": "string | null",
"checkInWay": "string | null",
"checkInOutPolicy": "string | null",
"childPolicy": "string | null",
"cleaningFee": "number | null",
"roomSize": "number | null",
"bedType": "string | null",
"bathrooms": "number | null",
"maxPax": "integer | null",
"roomPhotos": "string[] | null",
"amenities": "object | null",
"isActive": "boolean (restore deleted)"
}Response 200: Updated OtaListingDetail object
DELETE /api/v1/ota-listings/:id
Role: OTA_CONNECTIONS DELETE
Soft delete OTA listing (sets isActive: false). Restore via PATCH { "isActive": true }.
Response 204: No content
8. OTA Room Mappings
PUT /api/v1/ota-connections/:connectionId/room-mappings
Screen: S-07 | FR: FR-02
Bulk upsert room mappings.
Request:
{
"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:
{
"otaRoomTypes": [
{
"otaRoomId": "string",
"name": "string",
"ratePlans": [
{ "otaRatePlanId": "string", "name": "string" }
]
}
]
}8.5 OTA Inbound Reservations (Trip.com Push)
POST /api/v1/ota/trip/reservations [public]
Screen: (system integration) | FR: —
Inbound webhook for Trip.com to push reservation XML (OTA_HotelResRQ / OTA_HotelResModifyRQ / OTA_CancelRQ). Dispatch by root element; processes synchronously and answers XML RS. Authentication is POS credentials in the XML (BasicPropertyInfo/Requestor @ID/@MessagePassword), NOT JWT.
Endpoints:
- Production:
https://cms.ptxsolution.com/api/v1/ota/trip/reservations - Staging:
https://cms-st.ptxsolution.com/api/v1/ota/trip/reservations
Auth: POS RequestorID (@ID) + MessagePassword (@MessagePassword) validated against TRIP_INBOUND_AUTH_ID and TRIP_INBOUND_AUTH_PASSWORD env vars (constant-time HMAC-SHA256 compare).
Request Content-Type: text/xml or application/xml
Request body: XML conforming to one of:
OTA_HotelResRQ— New reservationOTA_HotelResModifyRQ— Modify existing reservationOTA_CancelRQ— Cancel reservation (stub: Type=2 "No implementation" until Trip.com publishes schema)
Response 200: Always HTTP 200, regardless of outcome (OTA convention). Body is XML RS with one of:
<OTA_HotelResRS><Success><HotelReservation ResStatus="S"><UniqueID Type="14" ID="{Booking.internalCode}"/></HotelReservation></Success></OTA_HotelResRS>(new creation)<OTA_HotelResRS><Success><Warning Type="3" Code="127"/></Success></OTA_HotelResRS>(duplicate/concurrent retry dedup via DB unique constraint)<OTA_HotelResRS><Success><Warning Type="3" Code="321"/></Success></OTA_HotelResRS>(modify with no changes)<OTA_HotelResRS><Errors><Error Type="3" Code="361"/></Errors></OTA_HotelResRS>(validation failure — see runbook for full error mapping)
Side effects:
- Raw XML (PAN-redacted) persisted to
ota_inbound_messagestable (durable log, before any processing) - Booking created or updated with guest email, phone, room count, guest count, special requests, and PAN-masked rawData
- Alert created on property if internal failure (Type=12 Code=450)
- Email sent to
TRIP_INBOUND_OPS_EMAILon internal error (fire-and-forget; silently skips if SMTP unconfigured)
Key validations (fail-fast, first error stops processing):
- OtaConnection exists for hotel code (HotelCode) and is linked to a property (propertyId not null)
- Room type mapping exists for OtaRoomId
- Rate plan mapping exists for OtaRatePlanId
- Check-in/check-out dates are parseable and checkOut is not in past
- Guest counts and ages are valid
- Currency code is 3-letter ISO
Reference: Full error mapping, validation details, and deployment runbook in docs/ota-trip-inbound-reservation.md.
9. Bookings
GET /api/v1/bookings
Screen: S-08 | FR: FR-03, FR-41
Query params:
country(optional, auto-applied for staff): VN|ID|MYpropertyId(optional)otaType(optional, comma-separated)status(optional):confirmed | cancelled | no_showotaStatus(optional): raw OTA status string filter (FR-41)checkInFrom(optional): ISO datecheckInTo(optional): ISO datesearch(optional): Guest name searchaf(optional): Advanced filters (URL-encoded JSON:FilterCondition[]). Example:?af=%5B%7B%22field%22%3A%22status%22%2C%22op%22%3A%22is%22%2C%22value%22%3A%22confirmed%22%7D%5D=[{"field":"status","op":"is","value":"confirmed"}]. See /bookings/filter-fields for available fields and operators.page(default 1)limit(default 25)sortBy(defaultcreatedAt): Field namesortOrder(defaultdesc):asc | desc
Response 200:
{
"data": [
{
"id": "string",
"propertyId": "string",
"propertyName": "string",
"roomTypeId": "string",
"roomTypeName": "string",
"otaType": "string",
"otaBookingId": "string",
"guestName": "string",
"guestEmail": "string | null",
"checkIn": "string (ISO date)",
"checkOut": "string (ISO date)",
"numRooms": "number",
"numGuests": "number",
"status": "string",
"otaStatus": "string | null",
"otaStatusUpdatedAt": "string (ISO datetime) | null",
"totalAmount": "number (integer)",
"currency": "string",
"createdAt": "string (ISO datetime)",
"bookingHealth": "ok | warning | risk (computed from settings.healthFlagRules)",
"healthFlags": "Array<{ name: string, severity: 'warning'|'risk', message: string }> (computed)"
}
],
"pagination": { "page": 0, "limit": 0, "total": 0, "totalPages": 0 }
}Note:
bookingHealthandhealthFlagsare computed at runtime from rules insettings.healthFlagRules(not stored on booking rows). See FR-51 / DB_DESIGN §3.10a.
GET /api/v1/bookings/:id
Screen: S-09 | FR: FR-03, FR-41
Response 200:
{
"id": "string",
"propertyId": "string",
"propertyName": "string",
"roomTypeId": "string",
"roomTypeName": "string",
"otaType": "string",
"otaBookingId": "string",
"guestName": "string",
"guestEmail": "string | null",
"checkIn": "string (ISO date)",
"checkOut": "string (ISO date)",
"numRooms": "number",
"numGuests": "number",
"status": "string",
"otaStatus": "string | null",
"otaStatusUpdatedAt": "string (ISO datetime) | null",
"totalAmount": "number (integer)",
"currency": "string",
"rawData": "object (original OTA response)",
"createdAt": "string (ISO datetime)",
"syncHistory": [
{
"otaType": "string",
"action": "push_availability",
"status": "completed | failed",
"timestamp": "string",
"error": "string | null"
}
]
}GET /api/v1/bookings/filter-fields
Screen: (filter UI) | FR: —
Returns metadata for advanced filter fields available on bookings list. Public endpoint, gated by Bookings VIEW permission.
Response 200:
{
"fields": [
{
"name": "propertyId",
"type": "string",
"label": "Property",
"operators": ["is", "isNot"],
"relatedModule": "properties"
},
{
"name": "status",
"type": "enum",
"label": "Booking Status",
"operators": ["is", "isNot", "isAnyOf", "isNoneOf"],
"valueOptions": ["confirmed", "cancelled", "no_show"]
},
{
"name": "checkIn",
"type": "date",
"label": "Check-In Date",
"operators": ["is", "isBefore", "isAfter"]
}
]
}Note: Field metadata does NOT include prismaPath (internal implementation detail). Use name to reference in FilterCondition.field.
GET /api/v1/bookings/export
Screen: S-08 | FR: FR-03
Same filters as GET /bookings (including af param). Returns CSV file.
Response 200: Content-Type: text/csv
GET /api/v1/bookings/:id/history
Screen: S-09 | FR: FR-03
Retrieve audit log entries for a specific booking. Returns up to 50 most recent entries.
Response 200:
{
"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:
{
"status": "string (required, target status key)",
"note": "string (optional)"
}Response 200: Updated booking object
Response 400: Invalid transition or target status not found
Response 403: User's role not permitted for this transition
Side effects:
- Audit log entry created
- Workflow transition hooks executed (audit_log, update_availability, send_notification)
PATCH /api/v1/bookings/:id/revert
Screen: S-09 | FR: FR-28 | Role: Bookings EDIT
Force-revert booking to its previous status, bypassing workflow transition rules. Looks up the last audit log entry and restores oldValue.status.
Request: { "note": "string (optional)" }
Response 200: { "id": "string", "status": "string (reverted status)", "previousStatus": "string (status before revert)" }
Response 400: No history found or booking is already in previous status
Side effects: Audit log entry created with revert: true flag in newValue
10. Availability
GET /api/v1/availability
Screen: S-10 | FR: FR-10
Query params:
propertyId(required)startDate(required): ISO dateendDate(required): ISO date
Response 200:
{
"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:
{
"roomTypeId": "string (required)",
"date": "string (ISO date, required)",
"blockedRooms": "number (required, 0 to unblock)"
}Response 200: Updated availability object
Side effect: Queues push_availability jobs for all connected OTAs
11. Rates
GET /api/v1/rates
Screen: S-11 | FR: FR-09
Query params:
propertyId(required)roomTypeId(required)startDate(required)endDate(required)
Response 200:
{
"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:
{
"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:
{
"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:
{
"propertyIds": ["string (uuid, required)[]"],
"adjustmentMode": "percentage | fixed | increment (required)",
"adjustmentValue": "number (required)",
"roomTypeNameFilter": "string (optional, partial match)"
}Response 200:
[
{
"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:
{
"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:
{
"propertyIds": ["string (uuid, required)[]"],
"planName": "string (required)",
"priceMode": "fixed | offset_percent | offset_amount (required)",
"priceValue": "number (required, min 0)",
"roomTypeNameFilter": "string (optional)"
}Response 200: { "created": "number (count of plans created)" }
12. Rate Rules
GET /api/v1/rate-rules
Screen: S-15 | FR: FR-15
Query params: propertyId (optional), isActive (optional)
Response 200:
{
"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:
{
"propertyId": "string (required)",
"roomTypeId": "string | null (null = all room types)",
"otaConnectionId": "string | null (null = all OTAs)",
"ruleType": "markup | discount | seasonal (required)",
"value": "number (required, percentage or fixed)",
"startDate": "string | null",
"endDate": "string | null",
"isActive": "boolean (default true)"
}Response 201: RateRule object
PUT /api/v1/rate-rules/:id
Partial update.
DELETE /api/v1/rate-rules/:id
Response 204: No content
13. Alerts
GET /api/v1/alerts
Screen: S-02 | FR: FR-07
Query params:
country(optional, auto-applied for staff): VN|ID|MYpropertyId(optional)alertType(optional):overbooking | sync_failure | session_expiredisResolved(optional):true | falsepage,limit
Response 200:
{
"data": [
{
"id": "string",
"propertyId": "string",
"propertyName": "string",
"alertType": "string",
"severity": "critical | warning | info",
"message": "string",
"isResolved": "boolean",
"resolvedBy": "string | null",
"resolvedAt": "string | null",
"createdAt": "string"
}
],
"pagination": {}
}PUT /api/v1/alerts/:id/resolve
Screen: S-02 | FR: FR-07
Request: { "notes": "string (optional)" }
Response 200: Updated alert with isResolved: true
14. Sync Jobs
GET /api/v1/sync-jobs
Screen: S-17 | FR: FR-04
Query params:
propertyId(optional)otaType(optional)jobType(optional):pull_bookings | push_availability | push_rates | verifystatus(optional):pending | running | completed | failedstartDate,endDate(optional)page,limit
Response 200:
{
"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:
{
"propertyId": "string | null (null = all)",
"jobType": "pull_bookings | push_availability (required)"
}Response 202: { "jobIds": ["string"], "message": "Sync jobs queued" }
DELETE /api/v1/sync-jobs/completed
Screen: S-17 | FR: FR-04
Clear completed jobs older than 7 days.
Response 200: { "deleted": "number" }
15. Analytics
GET /api/v1/analytics/revenue
Screen: S-14 | FR: FR-14
Query params: propertyId (optional), country (optional, auto-applied for staff), startDate, endDate, groupBy (day | week | month)
Response 200:
{
"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:
{
"dates": [
{
"date": "string",
"occupancyPercent": "number",
"totalRooms": "number",
"bookedRooms": "number"
}
]
}16. Settings
GET /api/v1/settings
Screen: S-16 | Role: U-01
Response 200:
{
"syncIntervals": {
"bookingPullMinutes": "number (default 3)",
"verificationMinutes": "number (default 10)",
"sessionRefreshMinutes": "number (default 30)"
},
"bufferRooms": "number (default 0)",
"notifications": {
"lineNotifyToken": "string | null",
"smtp": {
"host": "string | null",
"port": "number | null",
"user": "string | null",
"hasPassword": "boolean"
},
"alertTypes": {
"overbooking": "boolean",
"syncFailure": "boolean",
"sessionExpired": "boolean"
}
},
"auditLogRetentionDays": "number (default 90)"
}PUT /api/v1/settings
Screen: S-16 | Role: U-01
Partial update of settings. Password fields only sent if changed.
POST /api/v1/settings/test-notification
Screen: S-16 | FR: FR-07
Request: { "channel": "line | email" }
Response 200: { "success": "boolean", "message": "string" }
17. Users
GET /api/v1/users
Screen: S-16 | Role: U-01
Response 200: User list (without password hashes)
GET /api/v1/users/with-org ✅
Screen: Org Users page (/admin/organization/users) | Role: USERS.VIEW
Extended user list for the org admin directory. Includes companyId, company{ id, code, name }, and active memberships[] with department + companyTier nested selects. Used to render company/dept chips and primary indicator in one payload.
POST /api/v1/users
Screen: S-16 | Role: U-01
Request:
{
"email": "string (required)",
"name": "string (required)",
"password": "string (required, min 8 chars)",
"role": "manager | staff (required)",
"country": "string | null (VN|ID|MY, required for staff)",
"locale": "string (vi|id|ms|en, default: en)"
}PUT /api/v1/users/:id
Update user name, roleId, country, locale, isActive, or companyId.
companyId field: only super_admin can change a user's company (cross-company reassignment). Non-super_admin receives 403 if companyId is present in the body.
DELETE /api/v1/users/:id
Deactivate user. Role: U-01
POST /api/v1/users/:id/reset-password
Screen: S-16 (admin) | FR: FR-21 | Role: USERS.EDIT permission
Admin-initiated temporary password reset. Sets temporary password and forces user to change on next login.
Request:
{
"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
mustChangePasswordset 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:
{
"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:
{
"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:
{
"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
mustChangePasswordflag cleared (if set)- All refresh tokens revoked (session invalidation)
Note: If user has mustChangePassword=true, currentPassword is optional (allows reset without knowing old password).
18. Roles
GET /api/v1/roles
Screen: S-24 (Master Data) | FR: FR-20 | Role: Users VIEW
Response 200:
{
"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:
{
"name": "string (required, unique slug: lowercase + underscores)",
"label": "string (required)",
"description": "string (optional)",
"permissions": "object (required, { module: bitmask })"
}Response 201: Created role object
PATCH /api/v1/roles/:id
Role: Users EDIT
Partial update. name cannot be changed for system roles.
Response 200: Updated role object
DELETE /api/v1/roles/:id
Role: Users DELETE
Response 204: No content
Response 400: Cannot delete system roles
19. Countries
GET /api/v1/countries [public]
No auth required. Returns all active countries.
Response 200:
{
"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:
{
"code": "string (required, 2-char ISO alpha-2)",
"name": "string (required)",
"timezone": "string (required, IANA)",
"currency": "string (required, ISO 4217)",
"bgColor": "string (optional, hex)",
"textColor": "string (optional, hex)",
"sortOrder": "number (optional)",
"isActive": "boolean (optional, default true)"
}Response 201: Created country object
PATCH /api/v1/countries/:code
Role: Settings EDIT
Partial update. code (PK) cannot be changed.
Response 200: Updated country object
DELETE /api/v1/countries/:code
Role: Settings DELETE
Response 204: No content
20. Booking Status
GET /api/v1/booking-status
Returns all booking status definitions.
Query params:
includeDeleted(optional):true | false— include soft-deleted statuses
Response 200:
{
"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:
{
"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:
{
"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:
{
"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:
{
"fromKey": "string (required, must exist in booking_status_def)",
"toKey": "string (required, must exist in booking_status_def, different from fromKey)",
"allowedRoles": ["string (optional, empty array = all roles)"],
"hooks": ["string (optional)"],
"sortOrder": "number (optional, default 0)",
"isActive": "boolean (optional, default true)"
}Response 201: Created transition object
Response 409: Transition from→to already exists
PATCH /api/v1/booking-status-transitions/:id
Role: Settings EDIT
Partial update of transition (allowedRoles, hooks, sortOrder, isActive).
Response 200: Updated transition object
DELETE /api/v1/booking-status-transitions/:id
Role: Settings EDIT
Response 204: No content
20.5 Supplier Apartments
Lark-sourced apartment hierarchy (unmapped MANUAL support). Country-scoped for staff.
GET /api/v1/supplier-apartments
Screen: S-24 | FR: FR-31 (apartments list) | Role: Suppliers VIEW
Query params:
af(optional): Advanced filters (URL-encoded JSON:FilterCondition[]). Supported fields: propertyId, supplierId, roomType, specialist, hasInputError, unmatchedOnly, hasNoImages, cityId, districtId, buildingName.search(optional): text search in apartment code / name / addresspropertyId(optional)supplierId(optional)roomType(optional)specialistId(optional)hasInputError(optional): 'true'|'false'unmatchedOnly(optional): 'true'|'false' (show only apartments with no OTA mapping)hasNoImages(optional): 'true'|'false'country(optional, auto-applied for staff): VN|ID|MYcityId,districtId(optional)buildingName(optional)page(default 1),limit(default 25)sortBy(default 'createdAt'): createdAt|supplierApartmentCode|apartmentId|unitNumber
Response 200:
{
"data": [
{
"id": "string (uuid)",
"supplierApartmentCode": "string",
"apartmentId": "string (Lark ID)",
"unitNumber": "string | null",
"addressLine1": "string | null",
"city": "string | null",
"district": "string | null",
"buildingName": "string | null",
"roomType": "string | null",
"supplierId": "string (uuid)",
"supplierName": "string",
"specialistId": "string (uuid) | null",
"specialistName": "string | null",
"mappedPropertyId": "string (uuid) | null",
"propertyName": "string | null",
"hasImages": "boolean",
"hasInputError": "boolean",
"createdAt": "string (ISO datetime)"
}
],
"pagination": { "page": 0, "limit": 0, "total": 0, "totalPages": 0 }
}GET /api/v1/supplier-apartments/filter-fields
Screen: (filter UI) | FR: — | Role: Suppliers VIEW
Returns metadata for advanced filter fields available on supplier-apartments list.
Response 200:
{
"fields": [
{
"name": "supplierId",
"type": "string",
"label": "Supplier",
"operators": ["is", "isNot"],
"relatedModule": "suppliers"
},
{
"name": "specialist",
"type": "string",
"label": "Specialist",
"operators": ["contains", "notContains", "is", "isNot"],
"relatedModule": "users"
},
{
"name": "hasInputError",
"type": "boolean",
"label": "Has Input Error",
"operators": ["is"]
}
]
}GET /api/v1/supplier-apartments/groups
Returns apartment counts grouped by property/supplier/room-type. Honors same filters as /supplier-apartments.
Response 200:
{
"byProperty": [
{ "propertyId": "string", "propertyName": "string", "count": "number" }
],
"bySupplier": [
{ "supplierId": "string", "supplierName": "string", "count": "number" }
],
"byRoomType": [
{ "roomType": "string", "count": "number" }
]
}POST /api/v1/supplier-apartments
Screen: S-24 | FR: FR-31 | Role: Suppliers CREATE
Request:
{
"supplierApartmentCode": "string (required, unique within supplier)",
"apartmentId": "string (required, Lark ID)",
"unitNumber": "string | null",
"addressLine1": "string | null",
"city": "string | null",
"district": "string | null",
"buildingName": "string | null",
"roomType": "string | null",
"supplierId": "string (uuid, required)",
"specialistId": "string (uuid) | null",
"mappedPropertyId": "string (uuid) | null"
}Response 201: Created apartment object
PUT /api/v1/supplier-apartments/:id
Screen: S-24 | FR: FR-31 | Role: Suppliers EDIT
Request: Same as POST (partial update supported)
Response 200: Updated apartment object
DELETE /api/v1/supplier-apartments/:id
Screen: S-24 | FR: FR-31 | Role: Suppliers DELETE
Response 204: No content
DELETE /api/v1/supplier-apartments/clear-all
Screen: S-24 | FR: FR-31 | Role: Suppliers DELETE
Deletes all apartments for the user's country scope (staff) or all countries (manager). Use with caution.
Response 204: No content
20.6 Filter Views (Advanced Filter Saved Views)
User-owned saved filter configurations, company-scoped sharing. Filters are re-validated server-side on apply.
GET /api/v1/filter-views
Query params:
module(optional): 'supplier-apartments'|'bookings'|... (filter by module, default returns all)
Response 200:
[
{
"id": "string (uuid)",
"module": "string",
"name": "string",
"filters": [
{
"field": "string",
"op": "string",
"value": "string | number | string[]"
}
],
"isShared": "boolean",
"createdBy": {
"id": "string (uuid)",
"name": "string"
},
"createdAt": "string (ISO datetime)",
"updatedAt": "string (ISO datetime)"
}
]POST /api/v1/filter-views
Request:
{
"module": "string (required: 'supplier-apartments' | 'bookings')",
"name": "string (required, 1-255 chars)",
"filters": [
{
"field": "string",
"op": "string",
"value": "string | number | string[]"
}
],
"isShared": "boolean (optional, default false)"
}Response 201: Created FilterView object
Validations:
- Filters are validated against module's FilterRegistry (
apps/api/src/common/filtering/module-filter-registries.ts): field + op must match registered schema, values must fit constraints - Max 20 conditions per view
- Max 50 array values per condition
- Field operators are module-specific (e.g.
supplier-apartmentssupportssupplierId,specialist,hasInputError,unmatchedOnly, etc.)
Response 400: Invalid filters (field not in registry, operator mismatch, too many conditions, etc.)
PATCH /api/v1/filter-views/:id
Request: Partial update (name, filters, isShared)
Response 200: Updated FilterView object
Authorization: Only creator can mutate. Returns 403 if caller is not createdById, 404 if view does not exist.
DELETE /api/v1/filter-views/:id
Response 204: No content
Authorization: Only creator can delete.
21. Suppliers
GET /api/v1/suppliers
Screen: S-21 | FR: FR-31 | Role: Suppliers VIEW
Country-scoped for staff users.
Query params:
search(optional): name or code search (debounced)status(optional):active | inactivetier(optional):new— zero allocations OR created in the last 30 days (mirrors the frontend tier badge)country(optional, auto-applied for staff): VN|ID|MYpage(default 1),limit(default 25)sortBy(optional):name | supplierCode | country | createdAt(defaultcreatedAt),sortOrder:asc | desc
Response 200:
{
"data": [
{
"id": "string",
"supplierCode": "string",
"name": "string",
"country": "string (VN|ID|MY)",
"phone": "string | null",
"email": "string | null",
"bankName": "string | null",
"bankAccountNo": "string | null",
"bankAccountName": "string | null",
"airbnbProfileUrl": "string | null",
"notes": "string | null",
"isActive": "boolean",
"totalRooms": "number (sum of active allocations)",
"createdAt": "string (ISO datetime)"
}
],
"pagination": { "page": 0, "limit": 0, "total": 0, "totalPages": 0 }
}GET /api/v1/suppliers/stats
Screen: S-21 | FR: FR-31
Aggregated supplier list KPI stats. Used by frontend to avoid full-data fetch.
Query params:
country(optional, auto-applied for staff): VN|ID|MY
Response 200:
{
"total": "number (narrowed by ?country)",
"active": "number",
"inactive": "number",
"rooms": "number (allocation-row count across suppliers in scope)",
"newCount": "number ('new' tier: zero allocations OR created <30d)",
"byCountry": { "MY": "number", "VN": "number" }
}Note: byCountry ignores ?country (chips need all countries within the scope guard). Single country only — comma lists are not supported on stats endpoints.
GET /api/v1/suppliers/export
Role: Suppliers VIEW
Export suppliers as CSV. Country-scoped.
Response 200: Content-Type: text/csv
POST /api/v1/suppliers/import-batch
Role: Suppliers CREATE
Batch import suppliers from CSV rows. Country-scoped.
Request:
{
"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:
{
"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:
{
"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:
{
"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:
{
"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
| Screen | API Endpoints | FR |
|---|---|---|
| S-01 Login | POST /auth/login | — |
| S-02 Dashboard | GET /dashboard/summary, GET /dashboard/sync-status, GET /alerts, GET /activity-logs | FR-06, FR-07, FR-08, FR-25 |
| S-03 Properties List | GET /properties | FR-05 |
| S-04 Property Detail | GET/PUT /properties/:id, POST/PUT/DELETE room-types | FR-05 |
| S-05 OTA Accounts List | GET /ota-accounts | FR-01 |
| S-06 Connect OTA Account | POST /ota-accounts, POST /ota-accounts/:id/test | FR-01 |
| S-07 Import Properties | GET /ota-accounts/:id/discover-properties, POST /ota-accounts/:id/import-properties | FR-02 |
| S-08 Bookings List | GET /bookings, GET /bookings/export, GET /ota-status | FR-03, FR-41 |
| S-09 Booking Detail | GET /bookings/:id, GET /bookings/:id/history, PATCH /bookings/:id/status, PATCH /bookings/:id/revert, GET /ota-status | FR-03, FR-28, FR-41 |
| S-10 Availability Calendar | GET /availability, PUT /availability/block | FR-10 |
| S-11 Rate Manager | GET/PUT /rates | FR-09 |
| S-12 Booking Timeline | GET /bookings (date range) | FR-11 |
| S-13 Rate Parity | GET /rates/parity | FR-13 |
| S-14 Analytics | GET /analytics/* | FR-14 |
| S-15 Rate Rules | GET/POST/PUT/DELETE /rate-rules | FR-15 |
| S-16 Settings | GET/PUT /settings | FR-08 |
| S-17 Sync Job Log | GET /sync-jobs | FR-04 |
| S-18 User Profile | GET/PATCH /users/me, POST /users/me/password | FR-16, FR-17 |
| S-19 Role Management | GET/POST/PATCH/DELETE /roles | FR-20, FR-21 |
| S-20 Workflow Config | GET/POST/PATCH/DELETE /booking-status-transitions, PATCH /booking-status/:key/ui-config | FR-25, FR-28, FR-29 |
| S-21 Suppliers List | GET /suppliers, GET /suppliers/export, POST /suppliers/import-batch | FR-31 |
| S-22 Supplier Detail | GET /suppliers/:id, GET /suppliers/:id/room-allocations | FR-31 |
| S-23 Supplier Allocation Manager | GET/POST /room-types/:id/supplier-allocations, PUT/DELETE /supplier-allocations/:id | FR-32 |
| S-25 Customers List | GET /customers, GET /customers/suggestions | FR-33 |
| S-26 Customer Detail | GET /customers/:id, GET /customers/:id/bookings, POST /customers/:id/link, POST /customers/:id/unlink | FR-33 |
| S-27 Customer Form | POST /customers, PATCH /customers/:id | FR-33 |
| S-28 Customer Merge | POST /customers/:id/merge | FR-33 |
| S-24 Master Data | GET/POST/PATCH/DELETE /roles, GET/POST/PATCH/DELETE /countries, GET/POST/PATCH /booking-status, GET /booking-status/workflow, GET/POST/PATCH /ota-status | FR-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:
{
"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:
{
"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:
{
"id": "uuid",
"name": "string",
"email": "string | null",
"phone": "string | null",
"nationality": "string | null",
"notes": "string | null",
"isActive": "boolean",
"_count": { "bookings": "integer" },
"bookings": [
{
"id": "uuid",
"guestName": "string",
"guestEmail": "string",
"otaType": "booking | trip | expedia | agoda_hotel | agoda_homes",
"otaBookingId": "string",
"checkIn": "date",
"checkOut": "date",
"status": "string",
"totalAmount": "integer (smallest currency unit)",
"currency": "string (ISO 4217)",
"property": { "id": "uuid", "name": "string" },
"roomType": { "id": "uuid", "name": "string" }
}
],
"createdAt": "timestamp",
"updatedAt": "timestamp"
}Response 404: { "error": "Customer not found" }
POST /api/v1/customers
Create a new customer.
Required Permission: CUSTOMERS:CREATE
Request:
{
"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:
{
"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:
{
"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:
{
"bookingIds": ["uuid", "uuid", ...]
}Response 200:
{
"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:
{
"bookingIds": ["uuid", "uuid", ...]
}Response 200:
{
"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:
{
"sourceCustomerId": "uuid"
}Response 200:
{
"message": "Merged X bookings",
"mergedCount": "integer",
"targetCustomer": { ... }
}Response 400: { "error": "Cannot merge customer into itself" }
Response 404: { "error": "Source or target customer not found" }
24. OTA Status
OTA status normalization mappings (E-20). Maps raw OTA status strings to display labels and colors per OTA type. Requires SETTINGS permission for mutations.
GET /api/v1/ota-status
Screen: S-08, S-09, S-24 | FR: FR-40, FR-41, FR-42
Query params:
otaType(optional):booking | trip | expedia | agoda_hotel | agoda_homesincludeDeleted(optional):true | false(defaultfalse)
Response 200:
[
{
"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:
{
"rawStatus": "string (max 100)",
"otaType": "booking | trip | expedia | agoda_hotel | agoda_homes",
"label": "string (max 100)",
"color": "string (hex, e.g. #22C55E)",
"sortOrder": "number (optional, default 0)"
}Response 201: Created OtaStatusDef object
Response 400: { "error": "Mapping for \"confirmed\" on booking already exists" } (duplicate rawStatus + otaType)
PATCH /api/v1/ota-status/:id
Required Permission: SETTINGS:EDIT | FR: FR-40, FR-42
Request: (all fields optional; rawStatus and otaType are immutable)
{
"label": "string (optional)",
"color": "string (optional, hex)",
"sortOrder": "number (optional)"
}Response 200: Updated OtaStatusDef object
Response 404: { "error": "OTA status definition not found" }
PATCH /api/v1/ota-status/:id/delete
Soft-delete a mapping (isDeleted = true). Unmapped statuses fall back to raw string display.
Required Permission: SETTINGS:EDIT | FR: FR-42
Response 200: Updated object with isDeleted: true
PATCH /api/v1/ota-status/:id/restore
Restore a soft-deleted mapping.
Required Permission: SETTINGS:EDIT | FR: FR-42
Response 200: Updated object with isDeleted: false
Note: Legacy BPM endpoints (
process-types,process-status,process-instances) were planned but never implemented.
Booking workflow management uses thebooking-statusendpoints (§11 above).
25. Error Response Format
All error responses follow:
{
"error": "string (human-readable message)",
"code": "string (machine-readable code, e.g. VALIDATION_ERROR)",
"details": "object | null (field-level errors)"
}Standard HTTP Status Codes:
- 200: Success
- 201: Created
- 202: Accepted (async job queued)
- 204: No content (delete)
- 400: Validation error
- 401: Unauthorized
- 403: Forbidden (role mismatch or country scope violation)
- 404: Not found
- 409: Conflict (duplicate)
- 500: Internal server error
17. Organization (✅ IMPLEMENTED)
Status: Implemented in
apps/api/src/modules/{companies,company-tiers,departments,department-members}/.
Plan:plans/260418-2351-im-master-org-hierarchy/phase-03-api-crud.md.
Screens: S-34, S-35 | FR: FR-59, FR-60, FR-61, FR-62, FR-63 | Entities: E-28..E-32
Auth: All endpoints require JWT. Write endpoints requireadminrole.
17.1 Companies (E-28)
GET /api/v1/companies ✅
FR: FR-59 | Screen: S-34
Returns paginated companies including _count.users and _count.departments for the admin Companies list.
Response 200:
{
"data": [
{ "id": "uuid", "code": "PTX", "name": "PTX", "countryCode": "VN",
"timezone": "Asia/Ho_Chi_Minh", "isActive": true, "createdAt": "...",
"_count": { "users": 12, "departments": 4 } }
],
"pagination": { "page": 1, "limit": 25, "total": 1, "totalPages": 1 }
}GET /api/v1/companies/:id ✅
FR: FR-59 — returns same shape with _count.
POST /api/v1/companies ✅
FR: FR-59 | Role: super_admin only (enforced by SuperAdminGuard)
Request: { "code": "string", "name": "string", "countryCode": "VN|ID|MY|null", "timezone": "string", "isActive": true }
PATCH /api/v1/companies/:id ✅
Role: super_admin only.
DELETE /api/v1/companies/:id ✅
Role: super_admin only. Soft delete (isActive=false). 409 Conflict if _count.users > 0 or _count.departments > 0 — reassign dependents first.
17.2 Company Tiers (E-29)
GET /api/v1/companies/:companyId/tiers ✅
FR: FR-60
Response 200:
[
{ "id": "uuid", "code": "STAFF", "name": "Staff", "level": 1, "sortOrder": 0, "isActive": true },
{ "id": "uuid", "code": "LEAD", "name": "Team Lead", "level": 2, "sortOrder": 1, "isActive": true },
{ "id": "uuid", "code": "MANAGER", "name": "Manager", "level": 3, "sortOrder": 2, "isActive": true },
{ "id": "uuid", "code": "DIRECTOR", "name": "Director", "level": 4, "sortOrder": 3, "isActive": true }
]POST /api/v1/companies/:companyId/tiers ✅
Request: { "code": "string", "name": "string", "level": 1, "sortOrder": 0 }
Errors: 409 if (companyId, code) or (companyId, level) already exists.
PATCH /api/v1/tiers/:id ✅
DELETE /api/v1/tiers/:id ✅
Errors: 409 if tier referenced by active department_roles or department_members.
17.3 Departments (E-30)
GET /api/v1/companies/:companyId/departments ✅
FR: FR-61
Query: function, isActive, parentId, search
Response 200 (flat):
[
{ "id": "uuid", "code": "CS", "name": "Customer Support",
"function": "customer_support", "parentId": null,
"managerUserId": null, "memberCount": 3, "isActive": true }
]GET /api/v1/companies/:companyId/departments/tree ✅
FR: FR-61 | Screen: S-34
Response 200 (nested):
[
{
"id": "uuid", "code": "CS", "name": "Customer Support",
"function": "customer_support", "children": [
{ "id": "uuid", "code": "CS-VN", "name": "CS Vietnam", "children": [] }
]
}
]GET /api/v1/departments/:id ✅
Returns dept with roles[] (tier+title) and member count.
POST /api/v1/companies/:companyId/departments ✅
Request:
{
"code": "CS",
"name": "Customer Support",
"function": "customer_support",
"parentId": null,
"managerUserId": null,
"sortOrder": 0
}PATCH /api/v1/departments/:id ✅
Errors: 400 if parentId update creates cycle.
DELETE /api/v1/departments/:id ✅
Errors: 409 if department has active members.
POST /api/v1/departments/:id/roles ✅
FR: FR-62
Request: { "companyTierId": "uuid", "title": "Customer Support Manager" }
Errors: 400 if tier's companyId ≠ dept's companyId.
PATCH /api/v1/department-roles/:id ✅
Update title only.
DELETE /api/v1/department-roles/:id ✅
Errors: 409 if role has active members.
17.4 Department Members (E-32)
GET /api/v1/departments/:id/members ✅
FR: FR-63 | Screen: S-34
Response 200:
[
{
"id": "uuid",
"user": { "id": "uuid", "name": "...", "email": "..." },
"departmentRole": { "id": "uuid", "title": "CS Staff", "tier": { "code": "STAFF", "level": 1 } },
"isPrimary": true,
"isActive": true,
"assignedAt": "..."
}
]GET /api/v1/users/:userId/memberships ✅
FR: FR-63
POST /api/v1/department-members ✅
FR: FR-63 | Screen: S-35
Request:
{
"userId": "uuid",
"departmentId": "uuid",
"departmentRoleId": "uuid",
"isPrimary": false
}Errors:
- 400 if role doesn't belong to specified department, or tier mismatch
- 409 if
(userId, departmentId)already exists
PATCH /api/v1/department-members/:id ✅
Change role/tier within same department.
POST /api/v1/department-members/:id/set-primary ✅
Atomically un-flag previous primary, flag this one. Idempotent.
DELETE /api/v1/department-members/:id ✅
Remove assignment.
17.5 Error Codes (Org-specific)
| Code | HTTP | Meaning |
|---|---|---|
ORG_DEPT_CYCLE | 400 | parentId update would create cycle |
ORG_TIER_COMPANY_MISMATCH | 400 | Tier and Department belong to different Companies |
ORG_ROLE_TIER_MISMATCH | 400 | DepartmentMember's companyTierId ≠ role's companyTierId |
ORG_DEPT_HAS_MEMBERS | 409 | Cannot delete Department with active members |
ORG_ROLE_HAS_MEMBERS | 409 | Cannot delete DepartmentRole with active members |
ORG_MEMBER_EXISTS | 409 | User already a member of this Department |
18. Threads (Lead/Manager Layer)
Permission identity is derived from DepartmentMember.companyTier.code (STAFF | LEAD | MANAGER | DIRECTOR). DIRECTOR auto-inherits MANAGER privileges. Server-side authorization via lead-helpers is the source of truth; UI uses GET /users/me/dept-tiers for visibility only.
All privileged write endpoints below produce an AuditLog row automatically (via prisma-audit extension on Conversation, Participant).
Removed (2026-05): The v3.0.0 risk-flag endpoints (
GET /threads/risk,GET/POST/PATCH /risks/:bookingId) andPOST /threads/rescuewere removed along with thebooking_risktable. The threads console is now a booking-thread inbox without risk flagging.
GET /api/v1/users/me/dept-tiers
Returns { [DepartmentFunction]: TierCode } for the caller, omitting departments the caller is not in. Used by frontend useDeptTier hook.
GET /api/v1/threads/list
Cursor-paginated booking-thread feed for the caller (replaces former /api/v1/chat/war-room/threads). Filters: active, has_messages, unread_mine, assigned_mine. Auth scope: caller must be a Participant.
GET /api/v1/threads/unread-count
Single integer count of booking-linked conversations the caller has not fully read (sidebar badge). Replaces former /api/v1/chat/war-room/unread-count.
GET /api/v1/threads/managed?fn=customer_support
LEAD+ tier in the requested function. Returns conversations whose track is currently active for that function:
customer_support→booking.statusin active CS workflow (BookingStatusDef.isTerminal=false, department='cs')purchasing→booking.sourceStatusactivefinance→booking.accountingStatusactiveground_ops→booking.sourceStatus = 'src_confirmed'ANDcheckinTime IS NULLota→ has Participant from ota-function deptoperations→ no stage filter; LEAD+ sees every booking-linked thread
Returns: { items: ManagedThread[], total, page, limit }. 403 if caller is not LEAD+ in fn.
PATCH /api/v1/threads/:id/assignee
Body: { userId: UUID }. LEAD+ in scope. Demotes existing ADMIN participants to MEMBER, upserts target user as ADMIN. Returns the updated conversation with participants.
POST /api/v1/threads/:id/participants/backup
Body: { userId: UUID }. LEAD+ in scope. Idempotent — adds target user as MEMBER if not already a participant. Returns { ok: true }.
Audit Log Coverage
Every privileged write produces an AuditLog row:
entityType:conversation|participantaction:create|updateoldValue,newValue: JSON snapshots before/afterperformedBy: caller'suserId
18.1 Error Codes (Lead/Manager Layer)
| Code | HTTP | Meaning |
|---|---|---|
FORBIDDEN | 403 | Caller lacks required tier (LEAD+ or MANAGER+ depending on action) |
BAD_REQUEST | 400 | Booking not in cancelled state, or no contact info, or invalid input |
NOT_FOUND | 404 | Booking or conversation does not exist |