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(tableota_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_messagestable: 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 rootapps/api/src/modules/ota-inbound/handlers/— new-res, modify-res, cancel-res handlersapps/api/src/modules/ota-inbound/trip-reservation-validator.service.ts— Fail-fast validationapps/api/src/modules/ota-inbound/ota-inbound-response.builder.ts— XML RS constructionapps/api/src/modules/ota-inbound/ota-pan-redaction.util.ts— Parse-then-redact + Luhn sweepapps/api/src/modules/ota-inbound/ota-inbound-exception.filter.ts— Exception→XML RS mapperapps/api/src/modules/bookings/bookings.service.ts— UpdatedupsertFromOta()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
internalCodecolumn (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
EntityCodeModulewithEntityCodeService.resolve()(route param → UUID, zero queries for UUID passthrough) +generateFor()(6-hex with 8-hex fallback). - Frontend (apps/web):
lib/routes.tstyped builders enforce entity-object-only links (tsc gates hardcoded strings); all UUID displays replaced withinternalCode; 5 detail pages canonicalize legacy UUID params viarouter.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/statsextended: 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/sortOrderparams; 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: trueto 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 backgroundisValidating) - 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); amberUnmappedbadge; 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: optionalwidthClassNameprop (defaultsm: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;propertyIdnullable; unique key (otaAccountId, otaPropertyId) replaces (propertyId, otaAccountId)OtaConnection.ptxSync∈ {DONE, empty};OtaConnection.otaStatus∈SupplierApartmentmodel: 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-properties→import_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):
DepartmentFunctionenum: +ground_ops; seed addsGOdepartmentBookingRiskmodel +RiskCategoryenum (refund_heavy,customer_cancel,many_incidents);finalDecisiongated to MANAGER+Conversation: drop@uniqueonbookingId, add nullableparentConversationIdself-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 listPATCH /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 frontenduseDeptTiervisibility 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-statusRiskPanelmounted on booking detail — flag toggle, category dropdown, note textarea, finalDecision (MANAGER+ disabled state)RescueThreadButtonmounted on booking detail — visible only when CS LEAD+ AND status starts withcancelAND contact info presentThreadActionButtons(override + backup-tag user-picker modals) — uses sharedConfirmDialogfor danger actions perui-confirm-dialogs.mduseDeptTierhook inlib/use-dept-tier.tspowering UI visibility (serverLeadHelpersis the auth gate)
Constraints Honored:
- ❌ NO changes to 4 booking status fields (
status,paymentStatus,sourceStatus,accountingStatus) - ❌ NO new bits on
Role.permissions; existingPermissionsGuarduntouched - ❌ NO
isLead/isManagerbooleans — 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 orchestratorwar-room-thread-list.tsx— Virtualized thread list + paginationwar-room-list-item.tsx— Thread row (unread, ref, guest, timestamp)war-room-thread-panel.tsx— ReusesBookingThread+BookingThreadComposerwar-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; plan260422-1928-thread-functionmarked 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 — reusesThreadPanelfrom the chat module - Thread summaries for booking conversations fetched + WS-reconciled via
useThreadsForConversation
Files Touched:
apps/web/components/bookings/booking-thread-item.tsx— newMessageSquareaction +ThreadFooterunder body; edit/delete still owner-onlyapps/web/components/bookings/booking-thread.tsx— mountThreadPanel, consumeuseThreadsForConversation, passonReplyInThread/threadSummaryto 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.replyCountdrift (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.updatedWS payload now carriesrootMessageId(for client-side thread routing)- Frontend
threadId→rootMessageIdmap seeded fromlistThreadsresponse
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_REPLYnotification 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 timeapps/web/components/chat/message-actions.tsx— "Reply in thread" hover actionapps/web/components/notifications/notification-item.tsx—THREAD_REPLYbadge supportapps/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 replyGET /conversations/:id/messages/:rootId/replies— paginated replies (cursor)GET /conversations/:id/threads?since=— active threads for conv-list hydrationPOST /threads/:threadId/follow/DELETE /threads/:threadId/followPOST /threads/:threadId/mark-read
WS events added (published via Redis pub/sub from Phase A):
thread.created,thread.updated,thread.reply.created,thread.readmessage.created/edited/deletednow also published tochat:thread:{id}whenparentId != null
Schema additions (2nd thread migration):
NotificationType.THREAD_REPLYenum valueMessageDedup(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); bulkcreateForUsersfanoutmodules/chat/thread.controller.ts— 6 endpoints,ThreadsFeatureGuardat class level (404 when flag off)modules/chat/messages.service.ts— edit/delete of reply now publishes to both conv + thread channelsmodules/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
ThreadRootmodel: denormalized metadata (replyCount, lastReplyAt, participantIds) per conversation root message - [x] Added
ThreadReadStatemodel: per-user, per-thread last-read marker for unread count tracking - [x] Added
ThreadFollowmodel: explicit thread subscription for non-authors - [x] Updated
Usermodel 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 presencemodules/rooms.service.ts— Refactored. Uses Redis channel broadcasts instead of in-memory Mapmodules/presence.service.ts— Refactored. Presence data now in Redis with TTLmodules/typing.service.ts— Refactored. Typing indicators published to Redis channels
Env Vars Added:
REDIS_URL(preferred) orREDIS_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
/notificationspage 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:
Notificationtable (userId, type, title, message, read, createdAt, etc.) - Queue:
notification-retention(daily, prunes viareadAt < 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 pageapps/api/src/modules/alerts/alerts.service.ts— Emit notifications on overbooking/sync-failureapps/api/src/modules/bookings/bookings.service.ts— Emit on escalation/creationapps/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
/chatpage 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.tsxviauseChatContext()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 primitiveapps/web/components/chat/floating-*.tsx— 3 new components (button, launcher, drawer)apps/web/app/(dashboard)/layout.tsx— ChatProvider moved here (hoisted from/chatpage)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
Notificationrows with link to conversation/booking - [x] Actor attribution:
Notification.actorUserId+ frontend avatar chip (colored initial + name) - [x] Admin surface: Settings → Notifications tab toggle
Role.isGlobalNotificationAudienceflag - [x] Preference-aware fan-out:
InAppNotificationService.createForUsersfilters muted types per user - [x] Global audience recipient resolver: queries
role: { isGlobalNotificationAudience: true }
Architecture:
- DB:
UserNotificationPreferencetable (userId, type, inAppEnabled);Notification.actorUserIdFK;Role.isGlobalNotificationAudiencecolumn - API:
GET/PUT /api/v1/notifications/preferencesendpoints (user-scoped) - API:
PATCH /api/v1/roles/:idacceptsisGlobalNotificationAudience(admin-gated) - Frontend:
use-notification-preferenceshook with optimistic toggle + rollback - Frontend:
NotificationItemrendersActorChip(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 + endpointsapps/api/src/modules/chat/messages.service.ts— Chat mention fan-out to in-appapps/api/src/modules/notifications/recipients/— Query role flag for global audienceapps/web/components/notifications/— Actor chip + notification item updateapps/web/components/settings/— Global audience admin panel with confirm dialogpackages/database/prisma/schema.prisma— 3 new migrations (preferences table, actor FK, role flag)packages/types/src/notification-roles.ts— Deleted (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.tsexceeds 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
wslibrary + NestJS adapter - [x] Real-time message broadcast (
message.newevents) - [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 authRoomsService—Map<convId, Set<socket>>join/leave/broadcastPresenceService—Map<userId, Set<socket>>with transition-only emitsTypingService— Throttled typing events + auto-clearHeartbeatService— 25s ping/pong health checkWsAuthService— Cookie-based JWT validationBackfillController— REST endpoint for missed messages
Client: 3 new hooks in
apps/web/components/chat/useChat Socket— Native WebSocket + exponential backoffusePresence— Track online usersuseTyping— Typing indicator state
Code Changes:
apps/api/src/main.ts— WsAdapter wiredapps/api/src/modules/chat/chat.module.ts— Gateway + services registered conditionally viaCHAT_REALTIME_ENABLEDapps/api/src/modules/chat/messages.service.ts— Emitmessage.newafter DB commitapps/api/src/modules/chat/conversations.service.ts— Broadcastmessage.readon markReadapps/web/server.ts— Custom Next.js server with WS upgrade proxy (/api/ws/chat→ws://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:
useIsOnlinestale-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-opsand/rates/bulk-applysub-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
/customerslist 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
| Date | Version | Phase | Status | Key Deliverable |
|---|---|---|---|---|
| 2026-04-30 | 3.0.0 | Lead/Manager Layer | ✅ Complete | Tier-driven permission overrides + risk + rescue + threads consolidation |
| 2026-04-25 | 2.9.6 | War Room | ✅ Complete | Ops triage 3-pane dashboard (131 tests) |
| 2026-04-23 | 2.9.5 | Threads on Booking | ✅ Complete | Reply UI on booking detail page |
| 2026-04-22 | 2.9.4 | Thread Reconciliation | ✅ Complete | BullMQ replyCount sync + admin tools |
| 2026-04-20 | 2.8.0 | In-App Notifications | ✅ Complete | Notification inbox + retention |
| 2026-04-20 | 2.7.0 | Internal Chat Phase 04 | ✅ Complete | WebSocket realtime v1.1 |
| 2026-04-19 | 2.6.0 | Escalation + Consolidation | ✅ Complete | Target picker + master data unify |
| 2026-03-16 | 2.5.0 | Bulk Rates | ✅ Complete | FR-09 API + UI |
| 2026-03-02 | 2.4.0 | List Standardization | ✅ Complete | 4-zone layout |
| 2026-02-25 | 2.3.0 | Customer Management | ✅ Complete | Guest CRM + link/merge |
| 2026-02-13 | 2.2.0 | User Profile | ✅ Complete | Profile edit + auth flow |
| 2026-02-12 | 2.1.0 | Security Sprint | ✅ Complete | Activity log + auth hardening |
| 2026-02-01 | 2.0.0 | MVP | ✅ Complete | OTA sync core |
Last Updated: 2026-04-30