System Architecture
Project: PTX Channel Manager (ptx-cm)
Version: 3.0.5
Last Updated: 2026-06-05
v3.0.5 (June 2026): Trip.com inbound OTA push endpoint (POST /api/v1/ota/trip/reservations — single route, dispatch by XML root element OTA_HotelResRQ/OTA_HotelResModifyRQ/OTA_CancelRQ → booking create/update/cancel), PAN redaction, durable OtaInboundMessage log, XML-always responses (never JSON), error mapping (OTA codes 12/450 → "Unable to process"), soft delete fix for OtaListingDetail (isActive flag per system source-of-truth rule). OTA session handlers 4→5 (added TripSessionHandler). Biome 2.4.16 replaces ESLint (single quote, lineWidth 100). Module count clarified: ~46 feature modules; Backend model count: ~50 (vs. outdated "30" claims).
v3.0.4 (June 2026): Entity internal code module — 7 entities support human-readable codes (BK-XXXXXX, CU-XXXXXX, LD-XXXXXX, etc.); EntityCodeService resolves code-or-UUID route params (UUID passthrough, code lookup → 404 if missing); lib/routes.ts typed builders enforce compile-time safety (no hardcoded strings); frontend displays internalCode in headers/breadcrumbs (never UUID); POST/PATCH bodies always send real UUID (never route param). Frontend list page optimization v3.0.2 → v3.0.3 (stats/facets ~300B vs. fetch-all 2-3.5MB).
v3.0.3 (June 2026): List pages optimization — shared
use-paginated-listhook (frontend URL-synced page/sort/search, keepPreviousData flicker prevention), stats/facets endpoints (list-page optimization pattern), server-side sort whitelist (prevents arbitrary _count queries). All list pages use unified pattern (properties, suppliers, customers, ota-properties, bookings, supplier-apartments).v3.0.2 (June 2026): OTA Listings table + source list CRUD — soft-delete, system source-of-truth rule (after Lark import, re-imports insert-only, preserve user edits), internalCode (LD- prefix), pricing/policies/specs from Lark Source List table.
v3.0.1 (June 2026): Lark-style advanced filters — generic DSL (
FilterCondition[]via?af=param), per-module field registry,FilterViewsaved views (owner-only mutate, company-scoped sharing), FilterBuilder UI (React hooks + components), pilot modules (supplier-apartments, bookings). AND-only semantics, 1-level relations, server-side validation + registry drift prevention. Date semantics fixed to Asia/Ho_Chi_Minh TZ.v3.0.0 (May 2026): Lead/Manager permission layer + Threads consolidation. Mid-tier admin (LEAD+) can override assignee, backup-tag participants, flag booking risks (MANAGER+ sets finalDecision).
BookingRiskmodel with category + note. Conversation self-relation for rescue-thread linkage (CS LEAD+ on cancelled bookings). (Risk flags + rescue threads removed May 2026 —booking_riskdropped; see §2d.) Backend:/threads/*endpoints consolidate former/chat/war-room/*routes; env varNEXT_PUBLIC_WAR_ROOM_ENABLED→NEXT_PUBLIC_THREADS_ENABLED. Frontend:/threadsreplaces/war-room;/threads/managed(fn-scoped) +/threads/risk(OPS-only); tier-based nav viauseDeptTierhook. 47 new unit tests. All tier logic viaCompanyTier.code(no new booleans onDepartmentMember). v2.9.0–v2.9.6 baseline: Chat WS fanout via Redis pub/sub (stateless), Teams-style threaded replies, War Room ops triage dashboard (3-pane list/thread/info, 4 filter chips, 131 tests).
Overview
PTX Channel Manager is a unified OTA channel management platform that auto-syncs room availability across Booking.com, Trip.com, Expedia, and Agoda (Hotel/Homes) to prevent overbookings. The system uses a monorepo structure with a Next.js frontend, NestJS backend, PostgreSQL database, and Redis queue system.
1. High-Level Architecture Diagram
1b. In-App Notification Flow
2. Sync Engine Flow (Polling → Booking → Availability)
2b. WebSocket Realtime Flow (Chat v1.2 — Stateless Pods)
Architecture (v1.2): Chat WS fanout via Redis pub/sub backplane. API pods are stateless; all subscriptions stored in Redis with TTL. Supports ≥2 replicas.
Stateless: Redis pub/sub allows any pod to route to any client. Presence/subscriptions volatile (Redis TTL). Pod restart → auto-reconnect; offline broadcasts lost (acceptable for WS).
2c. OTA Inbound XML Push Pipeline (Trip.com OpenTravel)
Architecture (v3.0.5): Trip.com CM Notify model sends reservation notifications as XML (root element OTA_HotelResRQ / OTA_HotelResModifyRQ / OTA_CancelRQ). System accepts all message types at a single endpoint POST /api/v1/ota/trip/reservations, validates POS credentials, dispatches to handlers by root element, returns XML response (never JSON).
Handlers: root OTA_HotelResRQ → TripNewReservationHandler (create), OTA_HotelResModifyRQ → TripModifyReservationHandler (update), OTA_CancelRQ → TripCancelReservationHandler (cancel) — all dispatched from the single /ota/trip/reservations route.
OtaInboundMessage (audit log): otaType, messageType (res_new|modify|cancel), echoToken, otaBookingId, hotelCode, rawXml (PAN-redacted), outcome, errorCode, processingMs, remoteIp, createdAt.
Error Codes: POS auth fail / property not found → 12. Business rule violation → 450. All errors return HTTP 200 + XML RS (OTA requirement, never 4xx).
Key Features: PAN redaction before persist, immutable audit trail, constant-time POS comparison, XML-always responses, extensible handler registry.
2d. Threads Module: Lead/Manager Booking-Thread Inbox
Purpose: Unified console for booking-linked chat threads, gated by department tier (LEAD+) for inline assignment override and backup-tagging.
History: v3.0.0 also shipped risk flagging (
BookingRiskmodel,/threads/risk,/risks/:bookingId) and rescue threads (POST /threads/rescue). Both were removed in May 2026 (booking_risktable dropped); the console is now a pure thread inbox.
Routes (verified apps/api/src/modules/threads/threads.controller.ts):
GET /api/v1/threads/list— Paginated booking-thread feed (live-activity sort). Country-scoped.GET /api/v1/threads/unread-count— Unread count for sidebar badge.GET /api/v1/threads/managed?fn={function}— LEAD+ per-department view, filtered by the booking stage active for that function (operations= no stage filter, sees all).PATCH /api/v1/threads/:id/assignee— Override assignee (LEAD+ → Participant.ADMIN). Idempotent.POST /api/v1/threads/:id/participants/backup— Backup-tag user (LEAD+, idempotent MEMBER add).GET /api/v1/users/me/dept-tiers—{ [DepartmentFunction]: TierCode }for frontend visibility (useDeptTier).
Frontend Routes:
/threads— 3-pane inbox (gated byNEXT_PUBLIC_THREADS_ENABLED): thread list (searchable, filterable) | conversation (center) | booking context pane (right). Scoped styles inapps/web/app/threads.css./threads/managed— Per-function scoped view (LEAD+ only), components underapps/web/components/threads-managed/.
Authorization:
- Tier ladder: STAFF < LEAD < MANAGER < DIRECTOR (DIRECTOR inherits MANAGER privileges)
- LEAD+: override assignee, backup-tag
- Identity:
DepartmentMember.companyTier.code(single source of truth; no booleans)
Audit:
- All privileged writes logged:
Conversation,ParticipantinAUDITABLE_MODELS
Key Components (apps/web/components/threads/):
- Page & list:
thread-page.tsx,thread-list.tsx,thread-list-row.tsx,thread-list-filters.tsx,thread-list-search.tsx - Conversation:
thread-conversation.tsx(+ header/body/composer variants) - Context pane:
thread-context-pane.tsx(+ status strip, activity log, sections) - Hooks:
use-thread-list.ts,use-thread-url-state.ts,use-thread-list-invalidator.ts
2e. Floating Chat UI (Dashboard Integration)
Architecture Overview:
ChatProviderhoisted toapp/(dashboard)/layout.tsx— single context instance per tab, available to all dashboard routesFloatingChatLauncherrenders persistent FAB (z-30) + drawer (z-40) on authenticated pages- FAB hidden on
/chat*routes (avoid dual UI) - Drawer reuses existing
ConversationListandThreadcomponents (no duplication) - Real-time unread count via
useChatUnread()hook (WS-driven)
Component Hierarchy:
(dashboard)/layout.tsx
├─ ChatProvider (hoisted, single WS)
├─ FloatingChatLauncher
│ ├─ FloatingChatButton (FAB, z-30)
│ └─ Drawer (Radix Dialog, z-40)
│ └─ FloatingChatDrawer (list → thread state machine)
└─ Page content / nested routesZ-Index Convention:
- FAB:
z-30(visible, clickable, below drawer) - Drawer + overlay:
z-40(sits above FAB, below modals) - ConfirmDialog:
z-50(always on top of drawer) - Toaster:
z-[999999999](sonner default, never overridden)
See apps/web/components/ui/README.md for full z-index table.
2f. Advanced Filters (Lark-style DSL)
Purpose: Generic, module-scoped query language for list endpoints. Replaces per-endpoint custom filters with a unified FilterCondition DSL (AND semantics).
Architecture:
Backend (NestJS):
DSL Definition (
common/filtering/):FilterConditioninterface:{ field, op, value }where op ∈- Transport:
?af=encodeURIComponent(JSON.stringify(FilterCondition[]))(URL query param) - Hard limits: max 20 conditions, max 50 array values per condition
Field Registry (per-module):
- File:
{module}/module-filter-registry.ts→ exports whitelist of filterable fields - Format:
{ [fieldName]: { type: 'string'|'number'|'date'|'enum', prismaPath: 'path.to.field', relatedModule?: 'other-module' } } - 1-level relations only (e.g., 'property.name' OK, 'property.manager.name' not supported)
- Public metadata:
GET /{module}/filter-fields(gated by module permissions) → returns field metadata WITHOUT prismaPath
- File:
Filter Processing:
parseAfParam()— Decode & validate?af=param; enforce hard limitsbuildPrismaWhere()— Convert FilterCondition[] → Prisma where object (no loops, 1-level relations)applyAdvancedFilters()— Merge af where into existing where (country scope never clobbered)- Module registries centralized in
module-filter-registries.tsmap (NOT exported via barrel — avoids runtime cycles)
Pilot Modules (both updated for af support):
supplier-apartments— Registry: supplierId, propertyId, roomType, specialist (string), hasInputError (boolean), unmatchedOnly (boolean), hasNoImages (boolean)bookings— Registry: propertyId, otaType (enum), status, checkIn/checkOut (date ranges: isBefore, isAfter, is for day boundaries), guestName, bookingHealth
Date Semantics (fixed business TZ):
- Timestamptz day boundaries computed as Asia/Ho_Chi_Minh (+07:00), no DST
- Plain-date fields (@db.Date) compare as calendar dates (no TZ math)
- Currency fields & BigInt amounts coerced transparently
Saved Views (FilterView model):
- Table:
filter_views(id, module, name, filters, isShared, createdById, companyId, createdAt, updatedAt) - CRUD:
GET/POST/PATCH/DELETE /api/v1/filter-views(owned by creator, 403/404 on unauthorized mutate) - Visibility:
isShared: false→ creator-only;isShared: true→ company-scoped read (any user in company can see, but only creator can mutate) - Filters revalidated server-side on load/apply (prevents field-registry drift)
- Table:
Frontend (React):
Components (
apps/web/components/ui/filter-builder/):FilterBuilder— Button + dropdown panel with condition rowsFilterConditionRow— Field picker + operator picker (type-switched) + value input (text, number, date range, or entity multi-select)EntitySelect— Load-all dropdown for enums/related entities (e.g., booking status values, property list)FilterViewPicker— Save/load/delete saved views dropdown
Hooks (
filter-builder/):useAdvancedFilters(module)— Encode/decode af param, manage URL stateuseFilterFields(module)— Fetch & cache field registry from GET /{module}/filter-fieldsuseFilterViews(module)— CRUD saved views (POST create, PATCH update, DELETE remove)
Integration:
- Wired into
supplier-apartmentslist page +bookingslist page - Button trigger: FilterBuilder icon near search bar (Tailwind primary color)
- Value persists in
?af=query param + localStorage for drafts - i18n:
filters.*namespace (en, vi) — labels, operators, validation msgs
- Wired into
Extension Path (new module):
- Create
src/modules/{module}/module-filter-registry.tswith field whitelist - Add entry to
module-filter-registries.tsmap - Wire
af?: stringparam intoList{Module}Dto - Call
applyAdvancedFilters(where, af, '{module}')in service list method - Add
<FilterBuilder module="{module}" />to frontend list page - (Optional) Add custom field picker UI if module has special filtering logic
Bookings ↔ FilterView Coexistence Note:
Legacy localStorage-based saved views on /bookings screen remain for backward compatibility. New FilterView backend views are gradually migrated; consolidation deferred.
2g. Entity Internal Code Resolution (v3.0.4)
Purpose: Human-readable codes (BK-A1B2C3) alongside UUIDs on detail routes. Enables shareable URLs without exposing database IDs.
Supported Entities (7): Booking (BK), Customer (CU), Property (country-based), Supplier (SA), Department, OtaListingDetail (LD), SupplierApartment (SA).
Format: {PREFIX}-{6HEX} (auto-generated, collision-retry to 8 hex). EntityCodeService resolves:
- UUID input → passthrough (free, no DB hit)
- Code input → lookup by unique index → UUID or 404
- Detail routes accept code OR UUID; request bodies always send UUID
Frontend Integration: lib/routes.ts typed builders prevent UUID interpolation. Example: routes.booking({internalCode: 'BK-A1B2C3'}) → /bookings/BK-A1B2C3. Display shows internalCode in headers/breadcrumbs (never UUID).
3. Authentication & Authorization Flow
4. Database Schema (49 Models)
Domain Groups (verified against packages/database/prisma/schema.prisma):
- Auth & Users (5): users, roles, refresh_tokens, password_reset_tokens, activity_logs
- Organization (5): companies, company_tiers, departments, department_roles, department_members
- Master Data (4): countries, cities, districts, settings
- Inventory (6): properties, room_types, suppliers, supplier_organizations, supplier_apartments, supplier_room_allocations
- OTA (7): ota_accounts, ota_connections, ota_room_mappings, ota_listing_details, ota_inbound_messages, ota_status_def, sync_jobs
- Bookings (5): bookings, booking_status_def, booking_escalations, availability, customers
- Rates (8): rates, rate_rules, rate_plans, rate_plan_adjustments, ota_formula_templates, ota_rate_configs, ota_rate_lines, property_formula_configs
- Chat & Notifications (6): conversations, participants, messages, attachments, notifications, user_notification_preferences
- Operations (3): alerts, audit_logs, filter_views
Key Relations:
- Property ← [RoomTypes, OtaConnections (nullable propertyId), Bookings, SupplierApartments, OtaListingDetails]
- RoomType ← [Rates, RateRules, Availability, OtaRoomMappings, SupplierRoomAllocations, Bookings, OtaListingDetails]
- OtaAccount ← OtaConnection (unique on otaAccountId + otaPropertyId) ← [OtaRoomMappings, SyncJobs, Rates]
- Booking → 4 status axes via BookingStatusDef keys (status / paymentStatus / sourceStatus / accountingStatus), Customer, Supplier, Conversation (kind=BOOKING)
- OtaListingDetail → [RoomType, Property]; ← SupplierApartments (Lark source data post-import)
- OtaInboundMessage: standalone durable XML log (no FK; soft refs via hotelCode/echoToken)
5. Monorepo Structure
ptx-cm/
├── apps/
│ ├── api/ # NestJS 10 Backend
│ │ ├── src/
│ │ │ ├── modules/ # 46 feature modules
│ │ │ ├── common/ # Guards, filters, decorators
│ │ │ ├── utils/ # Helpers, crypto, pagination
│ │ │ └── main.ts # Bootstrap
│ │ └── package.json
│ │
│ └── web/ # Next.js 16 Frontend
│ ├── app/ # App Router
│ │ ├── (auth)/ # Public routes
│ │ ├── (dashboard)/ # Protected routes
│ │ └── api/[...path]/ # Backend proxy
│ ├── components/ # React components (27 domain dirs)
│ ├── hooks/ # Custom hooks
│ ├── lib/ # Utilities + context providers
│ └── package.json
│
├── packages/
│ ├── database/ # Prisma
│ │ ├── prisma/ # Schema + migrations
│ │ ├── src/
│ │ │ └── client.ts
│ │ └── package.json
│ │
│ ├── types/ # Shared TypeScript
│ │ ├── src/
│ │ │ ├── api.types.ts # Request/response DTOs
│ │ │ └── enums.ts # OtaType, AlertSeverity, etc.
│ │ └── package.json
│ │
│ └── config/ # Shared tsconfig presets
│ ├── tsconfig/ # base.json, nestjs.json, nextjs.json
│ └── package.json
│
├── docs/ # Documentation
│ ├── system-architecture.md # This file
│ ├── codebase-summary.md # File counts & module map
│ ├── code-standards.md # Naming, patterns, error handling
│ ├── project-overview-pdr.md # Requirements & PDR
│ ├── development-roadmap.md # Feature status & milestones
│ ├── SRD.md # System Requirements
│ ├── API_SPEC.md # REST API spec
│ ├── DB_DESIGN.md # Database design
│ └── UI_SPEC.md # Design system & screens
│
├── docker-compose.yml # PostgreSQL 16 + Redis 7
├── turbo.json # Turborepo config
├── pnpm-workspace.yaml # pnpm workspaces
└── package.json # Root config6. Backend Module Architecture
Core Modules (46 feature modules total)
| Module | Purpose | Key Files |
|---|---|---|
| auth | JWT authentication, login, refresh, password reset | auth.service.ts, jwt.strategy.ts |
| users | User CRUD, account settings | users.service.ts, users.controller.ts |
| roles | Role definitions, permission bitmasks | roles.service.ts |
| activity-logs | HTTP request logging, audit trail | activity-log.middleware.ts |
| notifications | Email service (Resend API + Mailpit in dev) | notifications.service.ts |
| in-app-notifications | Per-user notification inbox, retention job | in-app-notifications.service.ts, in-app-notifications.controller.ts |
| properties | Property CRUD, timezone/currency assignment | properties.service.ts |
| room-types | Room inventory, base rates | room-types.service.ts |
| room-mappings | OTA ↔ local room type mapping | room-mappings.service.ts |
| suppliers | Supplier/room-owner management | suppliers.service.ts |
| supplier-room-allocations | M:N supplier ↔ room allocation | supplier-room-allocations.service.ts |
| ota-accounts | Encrypted OTA credentials (AES-256-GCM) | ota-accounts.service.ts |
| ota-connections | Property ↔ OTA account links | ota-connections.service.ts |
| ota-adapters | Factory + 5 adapters (Booking, Trip, Expedia, Agoda Hotels, Agoda Homes) | ota-adapter.factory.ts |
| ota-rate-configs | Per-OTA rate formula configuration | ota-rate-configs.service.ts |
| ota-status | OTA-specific status definitions | ota-status.service.ts |
| ota-listings | OTA source listing inventory CRUD (pricing, policies, specs); soft-delete (isActive) | ota-listings.service.ts |
| ota-inbound | Trip.com inbound OTA XML push (HotelResRQ/Modify/Cancel), PAN redaction, durable logging | ota-inbound.service.ts |
| bookings | Booking CRUD, upsertFromOta dedup | bookings.service.ts |
| booking-status | Configurable status definitions | booking-status.service.ts |
| booking-status-transition | Status state machine transitions | booking-status-transition.service.ts |
| booking-hooks | Post-status-change side effects | booking-hooks.service.ts |
| customers | Guest CRM, booking consolidation, link/unlink/merge | customers.service.ts |
| availability | Calendar matrix, block/unblock date ranges | availability.service.ts |
| rates | Base rate management per room type | rates.service.ts |
| rate-rules | Markup/discount/seasonal rules | rate-rules.service.ts |
| rate-plans | Rate plan configurations with adjustments | rate-plans.service.ts |
| bulk-rates | Batch rate updates across properties | bulk-rates.service.ts |
| process-types | BPM workflow type definitions | process-types.service.ts |
| process-instances | Active workflow instance tracking | process-instances.service.ts |
| process-status | Process status definitions | process-status.service.ts |
| process-transitions | State machine transition rules | process-transitions.service.ts |
| sync-engine | Orchestration of polling, pulling, syncing | sync-engine.service.ts |
| sync-jobs | Job tracking with status | sync-jobs.service.ts |
| alerts | Overbooking detection, notifications | alerts.service.ts |
| chat | Internal messaging DM/channels/booking-context | chat.controller.ts, messages.service.ts |
| chat.realtime | WebSocket gateway, typing, presence, heartbeat | chat.gateway.ts, rooms.service.ts, presence.service.ts, typing.service.ts, heartbeat.service.ts |
| threads | Booking-linked chat threads, lead/manager tier ops (v3.0.0) | threads.controller.ts, threads.service.ts, lead-helpers.ts |
| risks | Booking risk flagging, categorization, decision tracking (v3.0.0) | risks.controller.ts, risks.service.ts |
| dashboard | Aggregated KPI metrics | dashboard.service.ts |
| settings | App config (booking_pull_minutes, etc) | settings.service.ts |
| health | Liveness probes | health.controller.ts |
| countries | Reference data for country filtering | countries.service.ts |
| prisma | Prisma client provider | prisma.service.ts |
Guard & Decorator Pattern
// Global guards (applied in order):
1. JwtAuthGuard // Validates JWT signature & expiry
2. PermissionsGuard // Checks module:action bitmask
3. CountryScopeGuard // Applies country filter to query
// Decorators to opt out or customize:
@Public() // Skip JwtAuthGuard
@RequirePermission(MODULE.PROPERTIES, ACTIONS.CREATE)
@CountryScope() // Injects countryScope param7. Frontend Route Architecture
| Route Pattern | Layer | Authentication |
|---|---|---|
/login, /forgot-password, /reset-password, /change-password | (auth) | Public |
/dashboard | (dashboard) | Protected + JWT |
/bookings, /bookings/[id], /bookings/new | (dashboard) | Protected + JWT |
/properties, /properties/[id] | (dashboard) | Protected + JWT |
/customers, /customers/[id] | (dashboard) | Protected + JWT |
/availability | (dashboard) | Protected + JWT |
/rates, /rates/base-rates, /rates/bulk-ops, /rates/bulk-apply | (dashboard) | Protected + JWT |
/ota-accounts, /ota-accounts/connect | (dashboard) | Protected + JWT |
/workflows, /workflows/designer, /workflows/instances | (dashboard) | Protected + JWT |
/workflows/process-types, /workflows/trigger-rules, /workflows/visibility | (dashboard) | Protected + JWT |
/process-instances | (dashboard) | Protected + JWT |
/suppliers, /suppliers/[id] | (dashboard) | Protected + JWT |
/alerts, /sync-jobs, /logs | (dashboard) | Protected + JWT |
/master-data, /settings, /profile | (dashboard) | Protected + JWT |
Context Provider Chain
AuthProvider (JWT + user state)
↓
CountryProvider (VN/ID/MY filter)
↓
ReferenceDataProvider (Countries cache)
↓
ThemeProvider (Light/dark mode)
↓
I18nProvider (en/vi locale)
↓
ActivityTrackerProvider (Session timeout)8. Tech Stack Summary
| Layer | Technology | Version |
|---|---|---|
| Frontend | Next.js + React + Tailwind CSS | 16 + 18 |
| Frontend State | SWR + Context API | - |
| Frontend Form | react-hook-form + zod | - |
| Frontend Tables | TanStack Table (react-table) | - |
| Backend | NestJS + Passport | 10 |
| Backend Validation | class-validator | - |
| Backend Queue | BullMQ + Redis | - |
| Database | PostgreSQL + Prisma ORM | 16 + 7 |
| Authentication | JWT + bcrypt | - |
| Encryption | AES-256-GCM | - |
| Build Tool | Turborepo + pnpm | - |
| Language | TypeScript | 5.7 |
| Linting & Formatting | Biome (replaced ESLint) | 2.4.16 |
| Testing | Jest + @nestjs/testing | - |
9. Key Design Patterns
Authentication Pattern
- JWT payload includes:
sub,email,roleId,permissions(PermissionMap),country,locale - Token extraction: HttpOnly cookie
access_token→ fallback to Bearer header - Guards applied per controller method or globally
Authorization Pattern
- Bitmask-based permissions per module (VIEW=1, CREATE=2, EDIT=4, DELETE=8)
- Role-based presets: super_admin, admin, manager, ota, cs, fin
- Country-scoped queries: all user queries filtered by
countryScopemiddleware
OTA Adapter Pattern
- Factory pattern:
OtaAdapterFactory.create(otaType)returns adapter instance - Strategy interface:
IOtaAdapter { fetchBookings(), pushAvailability() } - 5 implementations: BookingAdapter, TripAdapter, ExpediaAdapter, AgodaHotelAdapter, AgodaHomesAdapter
Sync Pipeline Pattern
- Repeating jobs via BullMQ scheduler (150s interval)
- Multi-stage processing: polling → booking pull → availability calc → OTA push
- Job tracking: SyncJob records created for audit + retry logic
Lark Import Pipeline (v3.0.1)
OTA Properties/Connections:
- Source: Lark Bitable table "2.3 Property List" → CSV export via PTX_SOP_LARK project (
export_lark_property_list.py) - Lookup: Lark select option IDs resolved via
lark_base_structure.json→ human labels (e.g. "2. Active to Sale" for OTA status) - Import:
pnpm --filter @ptx-cm/database import:ota-propertiesrunspackages/database/prisma/import_lark_ota_properties.ts - Idempotency: Upsert keyed on (otaAccountId, otaPropertyId); auto-creates placeholder OtaAccount per platform (label "X (Lark)", status expired)
- Matching: Building-name → property via cleanName logic (same as supplier apartments); unmapped listings stored with NULL propertyId
- Report: Created/updated/skipped/unmapped counts logged; first run: 1723 CSV rows → 1712 connections (11 skipped, 174 unmapped)
- Helpers: Shared
packages/database/prisma/lark_import_helpers.ts— cleanName, labelResolver, placeholder account creation
Source Listing Details (v3.0.2 — NEW 2026-06-05):
- Source: Lark "Source List" table → CSV export
- Import:
pnpm --filter @ptx-cm/database import:source-listingsrunspackages/database/prisma/import_lark_source_list.ts - Data: Pricing (costWd, costWk, margins), policies (check-in, restrictions, child policy), specs (roomSize, bedType, maxPax, amenities)
- Upsert Key: (roomTypeId, propertyId) — maps Lark source to internal room type
- Auto-Gen:
internalCode(LD-xxxxxx prefix) created viagenerateFor('OtaListingDetail', 'LD') - OPERATIONAL NOTE: After initial import, the SYSTEM database becomes source of truth. Script MUST NOT re-run in overwrite mode. Future re-imports insert NEW rows only (preserving user edits on existing rows). This protects operational data (custom margins, restriction rules, etc.).
- Soft-Delete: Listings can be deleted (isActive=false) and restored (PATCH { isActive: true }) without re-importing
Data Fetching (Frontend)
- SWR for data caching & revalidation
- Context providers for global state (auth, country, reference data)
- TanStack Table for sorting, pagination, filtering
10. Key Enums & Types
OTA Enums:
OtaType: booking, agoda, traveloka, expediaConnectionStatus: active, expired, error, requires_2faTwoFactorMethod: none, totp, manualSyncJobType: pull_bookings, push_availability, push_rates, verifySyncJobStatus: pending, running, completed, failed
Booking Enums:
AlertType: overbooking, sync_failure, session_expired, sla_breach, missing_deposit, missing_contact, unconfirmed_checkin, payment_mismatchAlertSeverity: critical, warning, infoAuditAction: create, update, delete
Rate Enums:
RateRuleType: markup, discount, seasonal
Permission Modules (15):
DASHBOARD, PROPERTIES, ROOM_TYPES, OTA_ACCOUNTS, BOOKINGS, CUSTOMERS, AVAILABILITY, SYNC_JOBS, RATES, ALERTS, SETTINGS, USERS, SUPPLIERS, COUNTRIES, ROLES
11. Deployment Architecture
Development:
- Monorepo dev servers:
pnpm devstarts both apps + BullMQ listeners - PostgreSQL in Docker: port 5433
- Redis in Docker: port 6379
- Mailpit (email catcher): ports 1025/8025
Production:
- Separate container images: API (port 3002), Web (port 3100)
- PostgreSQL managed service (Cloud SQL / RDS)
- Redis managed service (ElastiCache / Upstash)
- Email via Resend API
- Sync jobs persist in BullMQ (Redis)
12. Country Scope Module
Backend: apps/api/src/modules/country-scope/ — country-scope.config.ts, country-scope.guard.ts, country-scope.decorator.ts, country-scope.helpers.ts, index.ts
Frontend: apps/web/lib/country-scope/ — config.ts, country-context.tsx, country-selector.tsx, index.ts
Toggle
Default flag value: true (preserves existing behavior).
| Env | Effect |
|---|---|
COUNTRY_SCOPE_ENABLED=false | BE bypass: 19 modules unfiltered, all helpers short-circuit |
NEXT_PUBLIC_COUNTRY_SCOPE_ENABLED=false | FE bypass: provider returns country: 'all', selector renders null |
Strict parser: false, 0, no, empty string (case/whitespace-insensitive) → DISABLED. Anything else → ENABLED.
Re-enable Runbook
- Set both env vars
=true - Restart API process (BE config re-read on boot, idempotent boot log)
- Rebuild FE bundle:
pnpm --filter @ptx/web buildthen deploy (or restartpnpm devlocally — Next.js dev reads runtime NEXT_PUBLIC_* on first render) - Boot log confirms:
[CountryScope] ENABLED (env: COUNTRY_SCOPE_ENABLED=true)
Known Gaps
- WebSocket gateway (
apps/api/src/modules/chat/realtime/chat.gateway.ts) does not enforce country scope (pre-existing gap, not introduced by this module). Flag controls HTTP layer only.
Schema
User.country, Supplier.country, Property.country retained NOT NULL. Admins still enter country when creating data — flag only controls runtime filtering, not data shape.
Last Updated: 2026-06-05
Status: Active — v3.0.1 with Lark OTA listings data import pipeline; v3.0.0 lead/manager permission layer, threads consolidation, risk management, tier-based nav, country-scope feature flag