Interface Specification (API_SPEC)
| Project | PTX Channel Manager (ptx-cm) |
| Version | 2.1.0 |
| Date | 2026-02-20 |
| Type | REST API (JSON) |
| Base URL | /api/v1 |
1. Authentication
All endpoints require JWT Bearer token unless marked [public].
POST /api/v1/auth/login [public]
Screen: S-01 | FR: —
Login with email/password. Returns JWT access token + refresh token.
Request:
{
"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 |
- 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 |
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 | 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:
{
"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:
{
"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.tslogs 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_ENABLEDenvironment 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. 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/:id
Screen: S-04 | FR: FR-05
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 | 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:
{
"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:
{
"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
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
DELETE /api/v1/ota-connections/:id
Screen: S-04 | FR: FR-02
Response 204: No content
7. OTA Room Mappings
PUT /api/v1/ota-connections/:connectionId/room-mappings
Screen: S-07 | FR: FR-02
Bulk upsert room mappings.
Request:
{
"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. Bookings
GET /api/v1/bookings
Screen: S-08 | FR: FR-03
Query params:
country(optional, auto-applied for staff): VN|ID|MYpropertyId(optional)otaType(optional, comma-separated)status(optional):confirmed | cancelled | no_showcheckInFrom(optional): ISO datecheckInTo(optional): ISO datesearch(optional): Guest name searchpage(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",
"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:
{
"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:
{
"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
9. 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 |
10. 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"
}
]
}11. 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
12. 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
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 | 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" }
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:
{
"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"
}
]
}15. 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 |
| 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:
{
"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:
{
"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).
17. 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 |
18. 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
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:
{
"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 |
| 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
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 | inactivecountry(optional, auto-applied for staff): VN|ID|MYpage(default 1),limit(default 25)
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/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 |
| 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 | FR-03 |
| S-09 Booking Detail | GET /bookings/:id, GET /bookings/:id/history, PATCH /bookings/:id/status, PATCH /bookings/:id/revert | FR-03, FR-28 |
| 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-24 Master Data | GET/POST/PATCH/DELETE /roles, GET/POST/PATCH/DELETE /countries, GET/POST/PATCH /booking-status, GET /booking-status/workflow | FR-20, FR-21, FR-22, FR-25 |
24. 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