Skip to content

Project Changelog

Project: PTX Channel Manager (ptx-cm)
Last Updated: 2026-06-05 (v3.0.5 Trip.com inbound endpoint)


Version History

v3.0.5 — Trip.com CM OpenTravel Inbound Reservation Push (2026-06-05)

Status: ✅ CODE COMPLETE (endpoint live, OtaInboundMessage table, PAN redaction, error mapping)

Purpose: Official Trip.com Channel Manager partner integration (OpenTravel API 4.x). Single inbound endpoint accepts reservation XML (OTA_HotelResRQ / OTA_HotelResModifyRQ / OTA_CancelRQ), processes synchronously with PAN redaction before any DB write, answers HTTP 200 + XML RS per OTA spec. Deployment includes test simulator suite, staging stack, and runbook.

Backend (apps/api):

  • NEW endpoint: POST /api/v1/ota/trip/reservations (@Public, @SkipThrottle, text/xml content-type)
  • Auth: POS credentials in XML (RequestorID @ID/@MessagePassword) vs TRIP_INBOUND_AUTH_ID/TRIP_INBOUND_AUTH_PASSWORD env (constant-time HMAC-SHA256, no oracle)
  • Pipeline: PAN redaction → raw log persist → DOCTYPE reject → XML parse → POS auth → OtaAccount resolve (tenant scope) → dispatch by root element → handler → XML RS (HTTP 200 always)
  • NEW module: apps/api/src/modules/ota-inbound/ (controller, TripInboundService, 3 handlers, validator, response builder, PAN redaction util, exception filter)
  • NEW DB model: OtaInboundMessage (table ota_inbound_messages; fields: raw_xml redacted, otaAccountId, outcome received|success|warning|error, error_code, processing_ms, remote_ip)
  • MODIFIED: BookingsService.upsertFromOta() optional 4th param opts {roomMapping override, extra {guestEmail, guestPhone, numGuests, numRooms, specialRequests, rawData}}, returns {isNew, roomTypeId, bookingId, internalCode}, create+auditLog now in $transaction

Validation (fail-fast):

  • OtaConnection exists for HotelCode + linked to property (propertyId not null) → 3/375 Hotel not active
  • Room type mapping exists for OtaRoomId → 3/402 Invalid room type
  • Rate plan mapping exists for OtaRatePlanId → 3/249 Invalid rate code
  • Dates parseable, checkOut not past → 3/15 Invalid date, 3/353 Departure date is past dated
  • Guest ages 0-17 for qualifying code 8 → 3/320 Invalid value
  • Amounts present + positive → 3/320 Invalid value
  • Currency 3-letter ISO → 3/61 Invalid currency code
  • Reservation ID present (ResID_Type="14") → 3/315 confirmation number missing
  • Duplicate new: DB unique constraint P2002 → dedup, succeed with Warning Type=3 Code=127
  • Duplicate modify (no-diff): succeed with Warning Type=3 Code=321
  • Bad credentials: 4/497 Authentication error (constant-time compare)
  • Internal failure (retryable): 12/450 Unable to process + sync_failure Alert + ops email

Error Mapping: See docs/ota-trip-inbound-reservation.md (full table, validation details, error codes, Notify model) instead of duplicating in API_SPEC.

Database (migration 20260605025837_ota_inbound_messages):

  • ota_inbound_messages table: raw_xml (PAN-redacted before persist), otaAccountId FK, outcome, error_code, processing_ms, remote_ip, createdAt

Scripts & Testing:

  • NEW: scripts/trip-simulator/ CLI + self-cert + 10 XML fixtures (request/response pairs)
  • NEW: packages/database/prisma/seed-trip-inbound-test-data.ts (test data seeder)
  • NEW: docker-compose.staging.yml (ptx-cm-staging stack with Mailpit)

Env Vars:

  • TRIP_INBOUND_AUTH_ID (POS RequestorID from Trip.com)
  • TRIP_INBOUND_AUTH_PASSWORD (POS MessagePassword)
  • TRIP_INBOUND_OPS_EMAIL (ops alert recipient, default tien@enti.dev)
  • Reuses existing SMTP config (SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD)

Cancel Handler: Intentional Type=2 stub (Trip.com has not published OTA_CancelRQ schema). Pending account manager handoff.

Availability Recalc: Intentionally out of MVP — bookings persist and appear in UI; overbooking wiring is post-MVP.

Code Changes:

  • apps/api/src/modules/ota-inbound/trip-reservation-inbound.controller.ts — @Post('ota/trip/reservations')
  • apps/api/src/modules/ota-inbound/trip-inbound.service.ts — Main processing pipeline, dispatch by root
  • apps/api/src/modules/ota-inbound/handlers/ — new-res, modify-res, cancel-res handlers
  • apps/api/src/modules/ota-inbound/trip-reservation-validator.service.ts — Fail-fast validation
  • apps/api/src/modules/ota-inbound/ota-inbound-response.builder.ts — XML RS construction
  • apps/api/src/modules/ota-inbound/ota-pan-redaction.util.ts — Parse-then-redact + Luhn sweep
  • apps/api/src/modules/ota-inbound/ota-inbound-exception.filter.ts — Exception→XML RS mapper
  • apps/api/src/modules/bookings/bookings.service.ts — Updated upsertFromOta() signature
  • Migration 20260605025837, seed-trip-inbound-test-data.ts, scripts/trip-simulator/, docker-compose.staging.yml
  • env.example: +TRIP_INBOUND_AUTH_ID, +TRIP_INBOUND_AUTH_PASSWORD, +TRIP_INBOUND_OPS_EMAIL
  • docs/ota-trip-inbound-reservation.md (full runbook)

Tests: Unit tests for controller, validator, handlers, response builder (all OTA spec error codes covered).

Breaking Changes: None. BookingsService.upsertFromOta() backward compatible (opts param optional).

Known Behaviors:

  • HTTP 200 always (OTA convention) — Trip.com never sees JSON error envelope
  • PAN redacted in ota_inbound_messages table; rawData in Booking.rawData also masked
  • Email silently skips if SMTP unconfigured; alert always persists
  • Concurrent retry race: DB unique constraint (otaAccountId, otaPropertyId, resId) dedup wins
  • Tax math never rejected (logged only; spec examples don't reconcile under any formula)

v3.0.4 — Entity Internal Codes System (2026-06-05)

Status: ✅ CODE COMPLETE (schema, resolver, routes, UI migration)

Purpose: Replace raw UUID detail URLs with human-readable short codes (e.g. CU-A3F92B, MY-22846F, BK-7D21C4). Deterministic backfill from UUID suffix ensures old links never break.

Components:

  • Database: Added internalCode column (unique, NOT NULL) to Property, Customer, Booking, SupplierApartment tables; backfilled deterministically; collision repair via 8-hex variant.
  • Types (@ptx-cm/types): ENTITY_CODE_PREFIXES (CU/BK/SA), INTERNAL_CODE_REGEX, UUID/code validators, normalizeInternalCode().
  • Backend (apps/api): @Global EntityCodeModule with EntityCodeService.resolve() (route param → UUID, zero queries for UUID passthrough) + generateFor() (6-hex with 8-hex fallback).
  • Frontend (apps/web): lib/routes.ts typed builders enforce entity-object-only links (tsc gates hardcoded strings); all UUID displays replaced with internalCode; 5 detail pages canonicalize legacy UUID params via router.replace().

Result: All entity detail URLs now canonical (e.g. /properties/MY-22846F, /customers/CU-A3F92B). Old UUID URLs transparently resolve. No breaking changes.

Files changed: migration 20260605030417, entity-codes.ts, entity-code.{service,module,util}.ts, lib/routes.ts, 20+ link sites across web.

v3.0.3 — List Pages Data Display Optimization (2026-06-05)

Status: ✅ CODE COMPLETE (phases 1–6; zero breaking changes)

Purpose: Eliminate 2–3.5MB fetch-all antipattern across 5 list pages. Unified pagination stack via shared use-paginated-list hook + server-side stats/facets endpoints + TableLoadingOverlay + server-side sort whitelist.

Backend (apps/api):

  • GET /suppliers/stats extended: active/inactive/rooms aggregates per supplierType
  • New endpoints: GET /properties/stats, GET /customers/stats, GET /ota-connections/stats, GET /ota-connections/facets (country-scoped, ~300B payloads vs 2–3.5MB)
  • Server-side sort whitelist on all list controllers (sortBy/sortOrder params; backend validates against module whitelist; reject unknown fields)
  • Properties search widened: OR(name, buildingName, city) to match drawer client-side filter

Frontend (apps/web):

  • NEW hook lib/use-paginated-list.ts: SWR pagination + URL param sync (page, limit, sort, search, af filters) + 300ms debounce for search/filters + keepPreviousData: true to preserve page during refresh
  • NEW component components/ui/table-loading-overlay.tsx: dim 60% + small spinner, 150ms appear delay (avoids flicker on 30s SWR revalidate; does NOT dim on background isValidating)
  • NEW debounce helper in lib/use-api.ts
  • All 5 list pages rolled onto shared hook: ota-properties, properties, suppliers, customers, supplier-apartments → killed per-page pagination hand-rolling
  • OTA listing drawer map selector: replaced 5000-row fetch-all with async server search (properties?search=&limit=20, debounce 300ms, fetch only while dropdown open)

Result: First load payload ota-properties: 2–3.5MB → ~50KB; page change: previous rows persist (keepPreviousData), dim+spinner only ≥150ms; sort/pagination state instant (URL-driven, not latency-dependent).

Tests: Verified via live commit: tsc --noEmit web+api clean; web suite 122/122 green; api jest 42 suites/551 tests green.

Affected files: ota-properties/page.tsx canonical example; properties/page.tsx, suppliers/page.tsx, customers/page.tsx, supplier-apartments/page.tsx follow same pattern.

v3.0.2 — OTA Listings Compact Table + Detail Drawer (2026-06-04)

Status: ✅ CODE COMPLETE (frontend-only; zero API changes)

Purpose: /ota-properties was unusable — vertical scroll clipped by an overflow-hidden chain, 28 inline-edit columns ≈3,800px wide rendered as a near-blank spreadsheet despite 1,712 rows. Redesigned to the /properties pattern: compact read-only 8-column table + click-row → 520px right drawer where all fields are edited.

UI Changes (apps/web):

  • components/ota-properties/ota-properties-table.tsx: full rewrite (675 → 193 lines) — read-only 8 cols (Building, Platform, City, OTA Name, Property ID, PTX Sync, OTA Status, chevron); amber Unmapped badge; status dot with numeric-prefix-stripped label; SortableTh client-side sort (Building/City/OTA Status, null property sorts last)
  • app/(dashboard)/ota-properties/page.tsx: scroll container fixed (flex-1 overflow-auto bg-card, properties pattern); sticky header now sticks; pagination footer outside scroll area; sort state + drawer wiring (drawer row derived from refreshed SWR list)
  • NEW components/ota-properties/ota-listing-drawer.tsx: 520px drawer — Overview (platform badge, property ID, map/unlink building autocomplete) + Status/Reviews/Promotions/Links sections
  • NEW components/ota-properties/ota-listing-drawer-field.tsx + ota-listing-field-groups.ts: data-driven editable fields, save-on-blur (PATCH only on change) with amber/green/red flash; number parsing identical to old inline-edit (parseFloat/parseInt, NaN → null); Lark vocab selects verbatim (PTX Sync ''/DONE; OTA Status 7 values)
  • components/ui/drawer.tsx: optional widthClassName prop (default sm:w-[380px] unchanged for existing callers)
  • i18n: otaConnections.drawer.* (en + vi)

Behavior preserved: PATCH ota-connections/:id per field, PUT :id/map map/unlink, unlink-undo popup (page-level, survives drawer close), filters/search/country chips/pagination untouched.

Tests: 25 new (table rendering/sort accessors/status meta; drawer sections/select vocab/map-unlink; field save-on-blur/parsing). Full web suite 107/107 green; tsc --noEmit clean.

v3.0.1 — Lark OTA Listings Data Connection (2026-06-04)

Status: ✅ CODE COMPLETE (schema consolidated, import pipeline running, UI live)

Purpose: End-to-end Lark Bitable → PTX connection for OTA property listings. Consolidates building metadata (25 fields: otaPropertyName, ptxSync, propertyLink, bank, sourceStatus, otaStatus, ratePlan, etc.) into OtaConnection model. Supports unmapped listings (propertyId nullable) + auto-placeholder OTA accounts per platform. Includes supplier apartment hierarchy (Lark-sourced, MANUAL fallback).

Schema Deltas (migration 20260604165408_supplier_geo_ota_lark_consolidation):

  • OtaConnection: + 25 Lark metadata columns; propertyId nullable; unique key (otaAccountId, otaPropertyId) replaces (propertyId, otaAccountId)
  • OtaConnection.ptxSync ∈ {DONE, empty}; OtaConnection.otaStatus
  • SupplierApartment model: building hierarchy (Lark-sourced compound ID); source
  • ALL prior db-push drift consolidated: supplier_apartments, supplier_organizations, _SupplierProjects, PropertySalesStatus enum, properties/suppliers columns; dev DB: migrate resolve

API Surface (new endpoints under /api/v1):

  • GET /ota-connections (country-scoped, filters: country, channel, city, ptxSync, search; pagination)
  • PATCH /ota-connections/:id (metadata inline edit; MaxLength validation on strings)
  • PUT /ota-connections/:id/map (link/unlink building)
  • DELETE /ota-connections/:id (country-scope fallback to otaAccount.countryCode for unmapped)

Data Pipeline:

  • Source: Lark Bitable "2.3 Property List" → CSV export (PTX_SOP_LARK/export_lark_property_list.py)
  • Lookup: Lark select option IDs resolved via lark_base_structure.json
  • Import: pnpm --filter @ptx-cm/database import:ota-propertiesimport_lark_ota_properties.ts
  • Idempotent upsert keyed (otaAccountId, otaPropertyId); auto-creates placeholder OtaAccount ("X (Lark)" label, expired status)
  • Building-name → property matching via cleanName (same logic as supplier apartments)
  • Report: created/updated/skipped/unmapped counts; first run: 1723 CSV rows → 1712 connections (11 skipped, 174 unmapped)
  • Shared helpers: packages/database/prisma/lark_import_helpers.ts — cleanName, labelResolver, placeholder account

Frontend:

  • New route /ota-properties (sidebar "OTA Listings", module OTA_CONNECTIONS gated)
  • KPI cards: Total / Synced (ptxSync=DONE) / Active to Sale (otaStatus "2.") / Unmapped
  • Filter chips: Country, Channel, City (cascading)
  • Inline-edit grid: otaPropertyName, ptxSync, otaStatus, bank, reviewScore, etc. with MaxLength validation
  • Property Detail "OTA Mapping" tab upgraded: list + link/unlink modal
  • Vocabulary: OTA STATUS labels from Lark Bitable (0–6 prefixed)

Code Changes:

  • Backend: apps/api/src/modules/ota-connections/ (controller, service, DTOs with validation)
  • Backend: packages/database/prisma/import_lark_ota_properties.ts + lark_import_helpers.ts
  • Frontend: apps/web/app/(dashboard)/ota-properties/page.tsx + components/ota-properties/
  • Frontend: apps/web/components/properties/ota-connections-tab.tsx (upgraded)
  • .gitignore: + scratch/ (ad-hoc scripts may contain Lark credentials)
  • i18n: en.json, vi.json keys added for OTA_LISTINGS KPI + filter labels

Tests: Unit tests for controller + service (validation, country-scope, link/unlink logic). E2E coverage deferred.

Known Behaviors:

  • Unmapped listings: propertyId=NULL, still queryable and editable; country-scope falls back to otaAccount.countryCode
  • First import idempotent on re-run (upsert keyed by otaPropertyId)
  • Building-name matching case-insensitive, diacritics normalized (cleanName)
  • Lark select option labels must resolve via lark_base_structure.json (external lookup table)

v3.0.0 — Lead/Manager Permission Layer + Threads Consolidation (2026-04-30)

Status: ✅ CODE COMPLETE (migration applied; UI live behind NEXT_PUBLIC_THREADS_ENABLED)

Purpose: Mid-tier admin permission layer that lets users with CompanyTier.code ≥ LEAD override assignee, backup-tag participants, flag risk, and (MANAGER+) write final risk decisions on bookings within their department's active track. DIRECTOR auto-inherits MANAGER privileges. Identity uses the existing tier ladder — no new boolean flags on DepartmentMember.

Schema Deltas (additive only, migration 20260430084049_lead_manager_layer_phase01):

  • DepartmentFunction enum: + ground_ops; seed adds GO department
  • BookingRisk model + RiskCategory enum (refund_heavy, customer_cancel, many_incidents); finalDecision gated to MANAGER+
  • Conversation: drop @unique on bookingId, add nullable parentConversationId self-relation for rescue-thread linkage

API Surface (under /api/v1):

  • GET /threads/list + GET /threads/unread-count — booking-thread feed (consolidated from /chat/war-room/*)
  • GET /threads/managed?fn= — Lead/Manager-scoped list per department function (CS, Source, Accounting, GO, OTA, Operations)
  • GET /threads/risk — OPS-only risk-flagged thread list
  • PATCH /threads/:id/assignee — Override assignee (Participant.ADMIN model)
  • POST /threads/:id/participants/backup — Backup-tag user (idempotent MEMBER add)
  • GET / POST / PATCH /risks/:bookingId — Risk lifecycle (flag, category, note; finalDecision MANAGER+ only)
  • POST /threads/rescue — CS LEAD+ creates fresh conversation on cancelled booking (validates state + contact info)
  • GET /users/me/dept-tiers — Powers frontend useDeptTier visibility hook

Audit: All privileged writes auto-logged via existing prisma-audit extension (BookingRisk, Conversation, Participant added to AUDITABLE_MODELS).

Refactor — war-room → threads: Backend WarRoomController/Service merged into ThreadsModule; routes consolidate under /threads/*. Frontend components/war-room/ (22 files) → components/threads/; route /war-room/threads; sidebar nav, feature flag (THREADS_ENABLED), and i18n keys (nav.threads, thread.*) updated.

Frontend Pages & Components:

  • /threads/managed — fn-tab list, tier-gated tabs, inline override + backup-tag buttons per row
  • /threads/risk — OPS-only risk-flagged list with category, flagged-by, decision-status
  • RiskPanel mounted on booking detail — flag toggle, category dropdown, note textarea, finalDecision (MANAGER+ disabled state)
  • RescueThreadButton mounted on booking detail — visible only when CS LEAD+ AND status starts with cancel AND contact info present
  • ThreadActionButtons (override + backup-tag user-picker modals) — uses shared ConfirmDialog for danger actions per ui-confirm-dialogs.md
  • useDeptTier hook in lib/use-dept-tier.ts powering UI visibility (server LeadHelpers is the auth gate)

Constraints Honored:

  • ❌ NO changes to 4 booking status fields (status, paymentStatus, sourceStatus, accountingStatus)
  • ❌ NO new bits on Role.permissions; existing PermissionsGuard untouched
  • ❌ NO isLead/isManager booleans — single source of truth = DepartmentMember.companyTier.code
  • ❌ Auto-assign flow untouched
  • ❌ Mandatory check-points (e.g. no check-in without deposit) NOT bypassable by Lead/Manager
  • ❌ Shift logic explicitly out of scope (does not exist in code)

Tests: 47 new unit tests across lead-helpers.spec.ts, risks.service.spec.ts, threads.service.spec.ts. Pre-existing test suite unaffected. E2E infrastructure deferred (no apps/api/test/ exists).

Plan + Brainstorm:

  • plans/260430-1658-lead-manager-role-override/ (6 phases)
  • plans/reports/brainstorm-260430-1658-lead-manager-role-override.md

v2.9.6 — War Room: Ops Triage Dashboard (2026-04-25)

Status: ✅ CODE COMPLETE (feature-flagged; requires NEXT_PUBLIC_WAR_ROOM_ENABLED=true)

Purpose: Unified ops triage console for booking-linked chat threads. 3-pane layout: thread list (filtered, searchable, paginated) | booking thread panel | booking info sidebar. Supplements (does NOT replace) /chat and /bookings/[id].

Backend Features:

  • New endpoint GET /api/v1/chat/war-room/threads — single-query Prisma with filters (status, health, assigned-to, OTA), full-text search on booking ref, guest name, room type. Returns paginated threads (20 per page) with booking context (ref, guest, room, OTA, check-in, status).

Frontend Features (gated by NEXT_PUBLIC_WAR_ROOM_ENABLED):

  • New route /war-room — requires authentication
  • 3-pane layout: thread list (left) | booking thread (center) | booking info (right)
  • Filter chips: Booking Status (4 states) + Health (6 badges) + Assigned-to (user picker) + OTA (4 OTAs)
  • Debounced search (300ms) on booking reference, guest name, room type
  • Thread list item shows: unread badge, booking ref, guest name, last message, timestamp, thread open/close icon
  • Real-time updates via 20s SWR poll + CustomEvent invalidation on socket thread events
  • Booking info panel (reuse existing booking-detail atoms): summary, dates, guest, payment, current status
  • URL state: ?thread={threadId}&booking={bookingId} — deep-linkable, browser back/forward supported

Components (13 new under apps/web/components/war-room/):

  • war-room-page.tsx — Layout orchestrator
  • war-room-thread-list.tsx — Virtualized thread list + pagination
  • war-room-list-item.tsx — Thread row (unread, ref, guest, timestamp)
  • war-room-thread-panel.tsx — Reuses BookingThread + BookingThreadComposer
  • war-room-booking-info.tsx — Booking detail atoms (dates, guest, status)
  • Filter components: war-room-filter-status.tsx, war-room-filter-health.tsx, war-room-filter-assigned.tsx, war-room-filter-ota.tsx
  • Hooks: use-war-room-threads.ts, use-war-room-filters.ts, use-war-room-state.ts
  • Utils: war-room-filter-params.ts

Test Results: 94 frontend + 37 backend = 131 tests passing. All TS errors resolved; pnpm -w build green.

Feature Flag: FEATURES.WAR_ROOM_ENABLED (computed from NEXT_PUBLIC_WAR_ROOM_ENABLED)

Breaking Changes: None. No schema changes; reuses existing Thread, BookingThread, Message models.

Known Behaviors:

  • Opening a thread in War Room shares unread state with /bookings/[id] (same underlying thread ID)
  • Closing War Room thread in sidebar does not close the booking info panel (UX: swipe left on mobile to collapse thread)

Plan Reference: plans/260424-2353-war-room-booking-threads/ (8-phase implementation with brainstorm, design, and phase reports)

Also in this release:

  • Reverted: Thread reply UI feature (commit 63ae204) — feature was incomplete; thread schema remains unchanged; plan 260422-1928-thread-function marked stale/cancelled

v2.9.5 — Threads on Booking Detail Page (2026-04-23)

Status: 🚧 CODE COMPLETE (feature-flagged; requires NEXT_PUBLIC_FEATURE_THREADS=true)

Frontend Features Added (gated by NEXT_PUBLIC_FEATURE_THREADS):

  • "Reply in thread" hover action on each comment in the booking detail page thread (visible to all participants, not just owner)
  • Reply-count footer (avatars + "N replies · last reply Xm ago") under booking comments that have replies
  • Thread side panel (?thread=<messageId>) on booking detail pages — reuses ThreadPanel from the chat module
  • Thread summaries for booking conversations fetched + WS-reconciled via useThreadsForConversation

Files Touched:

  • apps/web/components/bookings/booking-thread-item.tsx — new MessageSquare action + ThreadFooter under body; edit/delete still owner-only
  • apps/web/components/bookings/booking-thread.tsx — mount ThreadPanel, consume useThreadsForConversation, pass onReplyInThread/threadSummary to each item

Backend: unchanged. Existing /chat/threads/* endpoints already support booking conversations (they key on messageId, not conversation kind).

Breaking Changes: None. Behavior when flag is off = identical to previous booking thread UI.

Test Results: 13/13 web tests pass; tsc clean on apps/web.


v2.9.4 — Thread Reconciliation + Admin Tools (Phase E) (2026-04-22)

Status: 🚧 CODE COMPLETE (ops rollout pending; feature-flagged off)

Backend Features Added:

  • BullMQ reconciliation job for ThreadRoot.replyCount drift (daily at 03:00 JST)
  • Admin endpoint POST /admin/chat/thread-reconcile (SuperAdminGuard) — manual trigger + dry-run mode
  • Structured logger events for reconciliation operations (no new monitoring libraries)

Phase D Follow-up Fixes:

  • thread.updated WS payload now carries rootMessageId (for client-side thread routing)
  • Frontend threadId→rootMessageId map seeded from listThreads response

Runbooks & Chaos Tests:

  • New: docs/runbooks/chat-thread-incidents.md — diagnostic procedures for thread issues
  • New: docs/chaos-tests/redis-outage-chat.md — Redis outage scenario testing

Breaking Changes: None. Admin endpoint behind SuperAdminGuard; feature remains flagged off.

Test Results: 156/156 API + 13/13 web tests pass.

Deferred (v3): Thread muting; reply search grouping; bulk reconciliation API.


v2.9.3 — Thread Frontend UI + Test Scaffold (Phase D) (2026-04-22)

Status: 🚧 IN PROGRESS (feature-flagged off; NEXT_PUBLIC_FEATURE_THREADS default false)

Frontend Features Added (gated by NEXT_PUBLIC_FEATURE_THREADS):

  • Thread side panel (desktop ≥1024px) / full-screen modal (mobile) with deep-link via ?thread=<uuid>
  • "Reply in thread" hover action on CHANNEL/BOOKING messages (hidden in DM, hidden when flag off)
  • Reply count footer ("N replies · Last reply Xm ago" + up to 3 avatars) under root messages
  • Follow/unfollow toggle (bell icon) in thread header
  • Thread-unread indicator in conversation list (distinct from main unread)

Test Framework Added:

  • vitest + @testing-library/react scaffold (no prior test framework)

Notification Type Added (user-visible):

  • THREAD_REPLY notification type (icon + label in notification list/dropdown)

Env Vars Added:

  • NEXT_PUBLIC_FEATURE_THREADS (default: false)

i18n Keys Added (en + vi):

  • chat.replyInThread, thread.replyCount, thread.replyCountOne, thread.lastReplyRelative, thread.follow, thread.unfollow, thread.participants, thread.rootDeleted, thread.replyPlaceholder, notifications.typeThreadReply

Code Changes:

  • apps/web/components/chat/thread-panel.tsx — Thread side panel/modal (responsive layout)
  • apps/web/components/chat/thread-reply-count.tsx — Reply footer with avatar stack + relative time
  • apps/web/components/chat/message-actions.tsx — "Reply in thread" hover action
  • apps/web/components/notifications/notification-item.tsxTHREAD_REPLY badge support
  • apps/web/hooks/use-thread-unread.ts — Thread unread count tracking (WS-driven)
  • apps/web/vitest.config.ts — Test framework config (new)

Breaking Changes: None. All UI behind feature flag; no API changes.

Test Results: Scaffold ready; component tests pending Phase D implementation completion.

Deferred (v3): Typing indicators in threads; thread muting; reply search grouping.


v2.9.2 — Thread Backend API + WS Events (Phase C) (2026-04-22)

Status: 🚧 IN PROGRESS (feature-flagged off; frontend Phase D pending)

REST endpoints added (gated by FEATURE_THREADS):

  • POST /conversations/:id/messages/:rootId/replies — create threaded reply
  • GET /conversations/:id/messages/:rootId/replies — paginated replies (cursor)
  • GET /conversations/:id/threads?since= — active threads for conv-list hydration
  • POST /threads/:threadId/follow / DELETE /threads/:threadId/follow
  • POST /threads/:threadId/mark-read

WS events added (published via Redis pub/sub from Phase A):

  • thread.created, thread.updated, thread.reply.created, thread.read
  • message.created / edited / deleted now also published to chat:thread:{id} when parentId != null

Schema additions (2nd thread migration):

  • NotificationType.THREAD_REPLY enum value
  • MessageDedup(clientMessageId, messageId) table for idempotent reply POSTs

Modules updated:

  • modules/chat/thread.service.ts — atomic Prisma transaction (reply insert + ThreadRoot upsert + replyCount increment + participant dedup + mention auto-follow); bulk createForUsers fanout
  • modules/chat/thread.controller.ts — 6 endpoints, ThreadsFeatureGuard at class level (404 when flag off)
  • modules/chat/messages.service.ts — edit/delete of reply now publishes to both conv + thread channels
  • modules/chat/mentions.parser.ts — reused unchanged for thread replies

Breaking Changes: None. All new endpoints feature-flagged off.

Test Results: 148/148 chat tests pass. No regressions.

Deferred (v2): ConversationMute model (fanout stub); message search / reply search grouping.


v2.9.1 — Thread Schema Models (Phase B) (2026-04-22)

Status: 🚧 IN PROGRESS

Schema Preparation:

  • [x] Added ThreadRoot model: denormalized metadata (replyCount, lastReplyAt, participantIds) per conversation root message
  • [x] Added ThreadReadState model: per-user, per-thread last-read marker for unread count tracking
  • [x] Added ThreadFollow model: explicit thread subscription for non-authors
  • [x] Updated User model with relations: threadReadStates, threadFollows
  • [x] Added feature flag: FEATURE_THREADS=false (disabled by default)

Env Vars Added:

  • FEATURE_THREADS (default: false)

Feature Status: Feature-flagged off. No user-facing changes; schema prep for Phase C (backend API) and Phase D (frontend UI).


v2.9.0 — Redis Pub/Sub Backplane for Stateless Chat (2026-04-22)

Status: ✅ SHIPPED

Infrastructure Refactor:

  • [x] Chat WS fanout migrated from in-memory (single pod) to Redis pub/sub
  • [x] Presence + room subscriptions stored in Redis with 30s TTL
  • [x] API tier now stateless — supports ≥2 replicas without event isolation
  • [x] New service: RedisPubSubService (ioredis client + pub/sub adapters)

Modules Updated:

  • modules/chat/realtime/redis-pub-sub.service.ts — New. Handles Redis sub/pub + SET/GET for presence
  • modules/rooms.service.ts — Refactored. Uses Redis channel broadcasts instead of in-memory Map
  • modules/presence.service.ts — Refactored. Presence data now in Redis with TTL
  • modules/typing.service.ts — Refactored. Typing indicators published to Redis channels

Env Vars Added:

  • REDIS_URL (preferred) or REDIS_HOST + REDIS_PORT + REDIS_PASSWORD (fallback)

Breaking Changes: None. Backward-compatible. Existing single-pod deployments continue to work.

Test Results:

  • WS message fanout verified across ≥2 replicas
  • Presence TTL expiry tested
  • No event loss on pod restart (clients auto-reconnect)

v2.8.0 — In-App Notifications (2026-04-20)

Status: ✅ SHIPPED

Features Delivered:

  • [x] Per-user notification inbox with bell icon + unread badge in dashboard header
  • [x] Dropdown preview showing last 5 notifications
  • [x] Full /notifications page with filters (all/unread/by type)
  • [x] Notification types: ALERT, BOOKING_EVENT, CHAT_MENTION, SYSTEM
  • [x] Smart recipient resolution: globalAudience, countryAdmins, companyAdmins
  • [x] Producer integration: alerts (overbooking, sync failures), bookings (escalation, creation), users (role changes), settings (config updates)
  • [x] Frontend: SWR polling (45s interval, jittered, pauses on hidden tab)
  • [x] Daily BullMQ retention job (prunes read notifications older than 90 days; unread kept indefinitely)

Architecture:

  • Server: InAppNotificationService (createForUsers, list, unreadCount, markRead, markAllRead)
  • DB: Notification table (userId, type, title, message, read, createdAt, etc.)
  • Queue: notification-retention (daily, prunes via readAt < now - NOTIFICATION_RETENTION_DAYS)
  • Env: NOTIFICATION_RETENTION_DAYS (default 90)
  • Fire-and-forget fan-out: void ... .catch(logger.warn) — never fails parent operations

Code Changes:

  • apps/api/src/modules/in-app-notifications/ — New module (service, controller, job processor)
  • apps/web/components/notifications/ — Bell icon, dropdown, inbox page
  • apps/api/src/modules/alerts/alerts.service.ts — Emit notifications on overbooking/sync-failure
  • apps/api/src/modules/bookings/bookings.service.ts — Emit on escalation/creation
  • apps/api/src/modules/users/users.service.ts — Emit on role change
  • Deps added: BullMQ job for retention (reuses existing queue infrastructure)

Test Results:

  • All notification service tests passing
  • Frontend SWR polling verified (no TanStack Query — repo standard)

Env Vars Added:

  • NOTIFICATION_RETENTION_DAYS (default: 90)

Breaking Changes: None. Additive feature; no schema breaking changes.

Success Metrics Met:

  • Unread count updates within 45s (+ jitter)
  • Fire-and-forget — parent operations never fail
  • Retention job runs daily, keeps unread indefinitely

v2.8.1 — Floating Chat Bubble with Unread Badge (2026-04-20)

Status: ✅ SHIPPED

Features Delivered:

  • [x] Persistent floating chat button (FAB) bottom-right on all authenticated dashboard pages
  • [x] Real-time unread count badge (WS-driven, 99+ cap)
  • [x] Right-anchored slide-in drawer (380px desktop, fullscreen mobile) with conversation list + thread drill-down
  • [x] Drawer primitive (@radix-ui/react-dialog) with accessible focus trap, ESC-to-close, return focus
  • [x] ChatProvider hoisted from /chat page to (dashboard)/layout.tsx — single WS per tab, app-wide unread access
  • [x] Hide FAB on /chat* routes; visible on all dashboard pages
  • [x] Reduced-motion support, safe-area insets for mobile notch/home-indicator

Architecture:

  • New primitive: components/ui/drawer.tsx — Right-anchored Dialog with Tailwind animation
  • New components: floating-chat-button.tsx, floating-chat-launcher.tsx, floating-chat-drawer.tsx
  • ChatProvider hoisted to app/(dashboard)/layout.tsx via useChatContext() hook
  • Z-index: FAB z-30 < Drawer z-40 < Modal z-50 (documented in components/ui/README.md)
  • Drawer footer: optional sticky zone for composer or close button

Code Changes:

  • apps/web/components/ui/drawer.tsx — New Drawer primitive
  • apps/web/components/chat/floating-*.tsx — 3 new components (button, launcher, drawer)
  • apps/web/app/(dashboard)/layout.tsx — ChatProvider moved here (hoisted from /chat page)
  • apps/web/app/globals.css — Drawer animation keyframes (.drawer-panel, .drawer-overlay)
  • apps/web/components/ui/README.md — Z-index convention table added

Dependencies Added:

  • @radix-ui/react-dialog@^1.1.2 (peer: @radix-ui/primitive)

i18n Keys Added:

  • chat.title, chat.floatingButton.label, chat.floatingButton.unreadLabel, chat.floatingDrawer.openFullChat, chat.drawer.empty (en, vi)

Breaking Changes: None. Additive feature; ChatProvider moved but no API change (same context shape).

Success Metrics Met:

  • FAB visible within 100ms of page load (no blocking)
  • Unread badge updates within WS latency (~50-200ms)
  • Drawer slide-in under 300ms (CSS animation)
  • Focus trap prevents keyboard-tab escape
  • Touch-friendly on mobile (full-height drawer)

v2.8.2 — In-App Notifications Follow-ups (2026-04-20)

Status: ✅ SHIPPED

Features Delivered:

  • [x] Per-user notification preferences (mute by type, in-app only)
  • [x] Chat mentions create in-app Notification rows with link to conversation/booking
  • [x] Actor attribution: Notification.actorUserId + frontend avatar chip (colored initial + name)
  • [x] Admin surface: Settings → Notifications tab toggle Role.isGlobalNotificationAudience flag
  • [x] Preference-aware fan-out: InAppNotificationService.createForUsers filters muted types per user
  • [x] Global audience recipient resolver: queries role: { isGlobalNotificationAudience: true }

Architecture:

  • DB: UserNotificationPreference table (userId, type, inAppEnabled); Notification.actorUserId FK; Role.isGlobalNotificationAudience column
  • API: GET/PUT /api/v1/notifications/preferences endpoints (user-scoped)
  • API: PATCH /api/v1/roles/:id accepts isGlobalNotificationAudience (admin-gated)
  • Frontend: use-notification-preferences hook with optimistic toggle + rollback
  • Frontend: NotificationItem renders ActorChip (colored by avatar service)
  • Mention parsing: @[Display Name](uuid) format; 140-char body truncation
  • Fire-and-forget: .catch(logger.warn) ensures producer never fails

Code Changes:

  • apps/api/src/modules/notifications/ — New preferences service + endpoints
  • apps/api/src/modules/chat/messages.service.ts — Chat mention fan-out to in-app
  • apps/api/src/modules/notifications/recipients/ — Query role flag for global audience
  • apps/web/components/notifications/ — Actor chip + notification item update
  • apps/web/components/settings/ — Global audience admin panel with confirm dialog
  • packages/database/prisma/schema.prisma — 3 new migrations (preferences table, actor FK, role flag)
  • packages/types/src/notification-roles.tsDeleted (hardcoded role list replaced by DB flag)

Test Results:

  • 67 scoped tests pass (preferences, actor include, link resolution, RBAC)
  • Full API + Web TypeScript check green

Breaking Changes: None (deleted notification-roles.ts but was not exported; internal use only).

Known Issues:

  • Critical-01 (resolved post-review): migration ordering fixed to prevent fresh-DB failure
  • Major-01: messages.service.ts exceeds 200 LoC (follow-up refactor planned)

v2.7.0 — Internal Chat Phase 04: WebSocket Realtime (2026-04-20)

Status: ✅ SHIPPED

Features Delivered:

  • [x] WebSocket gateway via native ws library + NestJS adapter
  • [x] Real-time message broadcast (message.new events)
  • [x] Typing indicators with throttling (1/sec) + auto-clear (3s idle)
  • [x] Presence tracking (online/offline) with multi-tab dedup
  • [x] Read receipts on last message
  • [x] Heartbeat service (25s ping, drop stale sockets after 2 missed pongs)
  • [x] Message backfill on reconnect via REST (GET /chat/conversations/:id/messages/backfill?sinceMessageId=)
  • [x] Client-side exponential backoff reconnect (1s → max 30s with jitter)
  • [x] BroadcastChannel multi-tab coordination (leader election)
  • [x] Feature flag: CHAT_REALTIME_ENABLED (env) + NEXT_PUBLIC_CHAT_REALTIME_ENABLED (frontend)

Architecture:

  • Server: 7 new services in apps/api/src/modules/chat/realtime/

    • ChatGateway — WebSocket entry point with JWT auth
    • RoomsServiceMap<convId, Set<socket>> join/leave/broadcast
    • PresenceServiceMap<userId, Set<socket>> with transition-only emits
    • TypingService — Throttled typing events + auto-clear
    • HeartbeatService — 25s ping/pong health check
    • WsAuthService — Cookie-based JWT validation
    • BackfillController — REST endpoint for missed messages
  • Client: 3 new hooks in apps/web/components/chat/

    • useChat Socket — Native WebSocket + exponential backoff
    • usePresence — Track online users
    • useTyping — Typing indicator state

Code Changes:

  • apps/api/src/main.ts — WsAdapter wired
  • apps/api/src/modules/chat/chat.module.ts — Gateway + services registered conditionally via CHAT_REALTIME_ENABLED
  • apps/api/src/modules/chat/messages.service.ts — Emit message.new after DB commit
  • apps/api/src/modules/chat/conversations.service.ts — Broadcast message.read on markRead
  • apps/web/server.ts — Custom Next.js server with WS upgrade proxy (/api/ws/chatws://localhost:3002/ws/chat)
  • Deps added: @nestjs/websockets@^10.4.22, @nestjs/platform-ws@^10.4.22, ws@latest

Test Results:

  • 86/86 chat-related tests passing (65 new realtime tests)
  • Code review: 6.5/10 → 2 critical bugs fixed (C1 proxy path, C2 backfill shape), major issues logged for v1.2

Known Issues (v1.2 backlog):

  • M1: Initial presence O(N) query per connect — batch fix pending
  • M2: Dual ping/pong channels (control-frame + JSON) — consolidate in v1.2
  • M3: Cookie not URL-decoded — add robustness in v1.2
  • M4: useIsOnline stale-closure pattern — refactor in v1.2
  • M5: Backfill missing global JwtAuthGuard — verify/add in v1.2
  • Multi-tab reconnect dedup race on fast-flip
  • Backfill no cursor limit exposed (silent cap at 200)

Breaking Changes: None. Polling fallback intact; feature is additive.

Migration Path: Flip CHAT_REALTIME_ENABLED=true in .env; no data migration needed.

Success Metrics Met:

  • Message latency: <500ms on same instance
  • Typing indicator response: <1s
  • Reconnect backoff: exponential 1-30s with jitter
  • Memory stability: 86 tests pass (no leaks detected in test harness)

v2.6.0 — Escalation Target Picker + Master Data Consolidation (2026-04-19)

Status: ✅ SHIPPED

Features:

  • Booking escalation target picker (dept tree → user selection)
  • Master data consolidation (removed booking ownership, unified UI)
  • Quick escalation flow with dept hierarchy

Previous Versions

v2.5.0 — Bulk Rate Updates (2026-03-16)

  • FR-09 Bulk Rate API (4 endpoints: preview, update-base-rate, create-rules, create-plans)
  • /rates/bulk-ops and /rates/bulk-apply sub-routes

v2.4.0 — List Page Standardization (2026-03-02)

  • 4-zone layout (header + filter + table + pagination)
  • FilterChip, ResizableHeader components
  • Unified across all list pages

v2.3.0 — Customer Management (2026-02-25)

  • CRUD for guests, booking linking, merge, fuzzy suggestions
  • /customers list page and detail page

v2.2.0 — User Profile & Auth (2026-02-13)

  • User profile management, password change, forgot password
  • Auth hydration (GET /users/me)
  • Theme toggle, password reset tokens

v2.1.0 — Security Sprint (2026-02-12)

  • Activity logging (middleware + dashboard)
  • Auth hardening (DB-backed refresh tokens, token rotation)
  • Country scope guard + access control
  • Frontend edge middleware for route protection

v2.0.0 — Core MVP (2026-02-01)

  • Anti-overbooking sync engine
  • 4 OTA adapters (Booking, Agoda, Traveloka, Expedia)
  • Booking pull + availability sync
  • Property & room management
  • Alerts + sync status dashboard
  • BPM workflow engine (4-axis booking status)

Revision History

DateVersionPhaseStatusKey Deliverable
2026-04-303.0.0Lead/Manager Layer✅ CompleteTier-driven permission overrides + risk + rescue + threads consolidation
2026-04-252.9.6War Room✅ CompleteOps triage 3-pane dashboard (131 tests)
2026-04-232.9.5Threads on Booking✅ CompleteReply UI on booking detail page
2026-04-222.9.4Thread Reconciliation✅ CompleteBullMQ replyCount sync + admin tools
2026-04-202.8.0In-App Notifications✅ CompleteNotification inbox + retention
2026-04-202.7.0Internal Chat Phase 04✅ CompleteWebSocket realtime v1.1
2026-04-192.6.0Escalation + Consolidation✅ CompleteTarget picker + master data unify
2026-03-162.5.0Bulk Rates✅ CompleteFR-09 API + UI
2026-03-022.4.0List Standardization✅ Complete4-zone layout
2026-02-252.3.0Customer Management✅ CompleteGuest CRM + link/merge
2026-02-132.2.0User Profile✅ CompleteProfile edit + auth flow
2026-02-122.1.0Security Sprint✅ CompleteActivity log + auth hardening
2026-02-012.0.0MVP✅ CompleteOTA sync core

Last Updated: 2026-04-30

PTX Channel Manager — Internal Documentation