diff --git a/.gitignore b/.gitignore index 1fd50e2..8297260 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ dist/ .env .env.local .env.production +.env.harden .env.*.local # IDE diff --git a/README.md b/README.md index 08fa843..915f72d 100644 --- a/README.md +++ b/README.md @@ -1,1207 +1,14 @@ -

- HAIP -

+# HAIP — Hotel AI Platform -

HAIP — Hotel AI Platform

+Open-source, API-first hotel PMS. Full project documentation lives on [`main`](https://github.com/TelivityAI/haip/blob/main/README.md). -

- The open-source, API-first hotel PMS where AI agents are first-class citizens. -

+## Production checklist -

- CI - TypeScript Strict - NestJS - PostgreSQL - Apache 2.0 License -1417 Tests Passing 12 AI Agents -

- -

- HAIP dashboard — reservations and AI agents -

- -

- What is HAIP · - Architecture · - AI Agents · - Features · - Tech Stack · - Quick Start · - API Reference · - OTAIP Integration · - Integrations · - Community & Support · - Contributing -

- ---- - -## What is HAIP - -The hotel industry runs on closed-source, legacy PMS platforms that charge per-room fees, lock data behind proprietary APIs, and treat integrations as an afterthought. Hotels pay $5–15/room/month just for the privilege of managing their own operations. - -HAIP is a **complete, production-grade hotel Property Management System** built from scratch with modern architecture. Reservation lifecycle, folio & billing, rate plans, housekeeping with digital checklists, night audit, groups & commercial (allotment + rooming lists), accounting & cashiering (A/R, deposits, cash drawers, GL codes), channel distribution to 450+ OTAs, a **full commission-free direct booking engine** (guest-facing widget + public booking API) so hotels take reservations straight from their own website, **pluggable payment gateways** (Stripe and other PSPs), Keycloak authentication, local user & role administration, media management for property and room photos, tax calculation, fiscal invoicing & guest-registration providers, an **integration registry** for adapters and providers, door-lock credential hooks, revenue management — and **12 built-in AI agents** that orchestrate revenue strategy, optimize pricing, predict cancellations, detect audit anomalies, prioritize receivables collections, forecast group pickup, schedule housekeeping, automate guest communications, and draft review responses. It even ships a **ChatGPT gateway** so guests can search and book a room by chatting. Dashboard locales include English, German, and Portuguese (BR). All open source under Apache 2.0. - -What makes HAIP different is that **AI agents are built into the architecture from day one** — not as a bolt-on, but as first-class citizens with their own lifecycle, decision logging, and per-property calibration (deterministic engines that learn each hotel's own rates from its history — not LLMs pretending to be agents). HAIP is the sister project to [OTAIP](https://github.com/telivity-otaip/otaip) (Open Travel AI Platform). Together they form **Telivity's open-source travel infrastructure**. OTAIP agents connect to HAIP via the Connect API — the PMS works without AI, but the AI makes it extraordinary. - -### What HAIP is NOT - -HAIP is not a wrapper around another PMS. It's not a SaaS dashboard with "AI" slapped on the marketing page. It's a real PMS with real hotel operations logic — night audits at 3am, folio routing rules, rate parity enforcement, guest registration compliance across jurisdictions. And it ships a **commission-free direct booking engine** so a hotel can take bookings straight from its own website — keeping the 15–25% an OTA would take. - ---- - -## Architecture - -```mermaid -graph TB - subgraph Clients - Dashboard["React Dashboard
(Vite + TanStack Query)"] - BookingWidget["Booking Widget
(Direct Booking Engine)"] - ChatGPT["ChatGPT Gateway
(Connect GPT Actions)"] - OTAIP["OTAIP Agents
(Hotel Search · Rate Compare · Booking)"] - ThirdParty["Third-Party Systems
(Webhooks · Adapters)"] - end - - subgraph HAIP["HAIP PMS (NestJS)"] - direction TB - REST["REST API
OpenAPI 3.0"] - WS["WebSocket Gateway
Real-time Events"] - Connect["Connect API
OTAIP Agent Layer"] - Auth["Keycloak Auth
OAuth 2.0 + RBAC"] - - subgraph Modules - direction LR - Reservation["Reservations
Lifecycle + Availability"] - Folio["Folio & Billing
Charges + Routing"] - Room["Rooms
Status State Machine"] - Guest["Guest Profiles
VIP + Preferences"] - RatePlan["Rate Plans
BAR + Derived + Restrictions"] - Payment["Payments
Pluggable Gateways"] - Tax["Tax Engine
Jurisdiction-based"] - HK["Housekeeping
Tasks + Checklists + Inspection"] - NA["Night Audit
Automated Day Close"] - Channel["Channel Manager
ARI Sync + Rate Parity"] - BookingEngine["Booking Engine
Public API + Widget"] - Groups["Groups & Commercial
Allotment + Rooming Lists"] - Accounting["Accounting & Cashier
A/R · Deposits · GL"] - Fiscal["Fiscal & Compliance
Invoicing + Guest Reg"] - Integrations["Integration Registry
Adapters + Providers"] - DoorLocks["Door Locks
Credential Hooks"] - Reports["Reports
Revenue + Occupancy + KPIs"] - end - - subgraph AI["AI Agent Framework — 12 agents"] - direction LR - RManager["Revenue Manager
(Orchestrator)"] - DemandAgent["Demand
Forecasting"] - PricingAgent["Dynamic
Pricing"] - ChannelMix["Channel-Mix
Optimization"] - Overbooking["Overbooking
Management"] - GroupPickup["Group Pickup
Forecasting"] - AuditAnomaly["Night Audit
Anomaly Detection"] - HKOptimizer["Housekeeping
Optimization"] - CancelPredict["Cancellation
Prediction"] - ARCollections["A/R Collections
Prioritization"] - GuestComms["Guest
Communication"] - ReviewResp["Review
Response"] - end - - Webhook["Webhook Engine
entity.action Events"] - end - - subgraph Infrastructure - PG["PostgreSQL 16
(Multi-tenant)"] - Redis["Redis 7
(Cache + Pub/Sub + Queues)"] - Keycloak["Keycloak
(Identity Provider)"] - Payments["Payment Gateways
(Stripe + pluggable PSPs)"] - OTAs["OTA Channels
Direct + Aggregators · 450+"] - end - - Dashboard -->|HTTP + WebSocket| REST - Dashboard -->|Real-time| WS - BookingWidget -->|HTTP| BookingEngine - ChatGPT -->|REST| Connect - OTAIP -->|REST| Connect - ThirdParty -->|REST| REST - ThirdParty -->|REST| Integrations - - Auth --> Keycloak - REST --> Auth - REST --> Modules - Connect --> Modules - Modules --> PG - Modules --> Redis - AI --> PG - AI --> Modules - Channel --> OTAs - Payment --> Payments - Webhook -->|POST| ThirdParty - WS -->|Broadcast| Dashboard -``` - -**Option B Architecture** — The PMS is standalone. It works without OTAIP. OTAIP agents sit on top via the Connect API, using purpose-built endpoints for AI agent workflows. - -### Key Design Decisions - -- **Multi-tenant from day one** — `property_id` on every table, designed for portfolio operators managing multiple hotels -- **Event-driven** — Webhook events on every state change (`reservation.created`, `folio.charge_posted`, `room.status_changed`). Build anything on top. -- **AI agents as first-class citizens** — 12 built-in agents with a common interface: `analyze() → recommend() → execute()`, coordinated by a Revenue Manager orchestrator. Three operating modes: manual, suggest, autopilot. Decision logging for continuous learning. -- **ChannelAdapter pattern** — Same abstraction as OTAIP's ConnectAdapter. Booking.com + Expedia (EQC) direct adapters plus SiteMinder and DerbySoft aggregators for 450+ OTA reach — distributing **both** ARI and descriptive content (photos/descriptions/amenities). -- **Layered RBAC** — Keycloak JWT authentication **plus HAIP's own local users, roles & permissions**: a code-defined permission catalog, operator-defined custom roles, and guards (`@Roles` + `@RequirePermissions`) on every endpoint. -- **Polymorphic media** — One image model for properties, room types & rooms; add by URL (zero infra) or upload to S3/MinIO, with one enforced primary per owner. -- **Compliance as infrastructure** — PCI tokenization via pluggable payment gateways, GDPR audit trails, jurisdiction-based tax calculation, fiscal document hooks, guest registration per jurisdiction. Not bolted on — built in. -- **Integration registry** — channels, payments, messaging, fiscal, door locks, and more as discoverable adapters/providers — not one-off hardwires. -- **Real-time dashboard** — WebSocket broadcasting per property. Room status changes, new reservations, AI agent decisions — all pushed instantly. - ---- - -## AI Agents - -HAIP includes **12 built-in AI agents** — 5 for revenue management (including the Revenue Manager orchestrator), 5 for operations intelligence, and 2 for guest engagement. Every agent follows the `HaipAgent` interface: - -``` -analyze() → recommend() → execute() → recordOutcome() → train() -``` - -### Orchestration (HAIP-hosted) - -HAIP orchestrates its own agents — there is no generic pipeline engine. **RManager** runs the revenue subgraph (`demand_forecast` → pricing / overbooking / channel_mix / group_pickup → one strategy). Ops and guest agents run on external cron or events. **OTAIP** orchestrates OTAIP agents over the Connect API only (Option B). Graph source of truth: `apps/api/src/modules/agent/agent-graph.ts`. Details: [`docs/agents-orchestration.md`](./docs/agents-orchestration.md). - -### Operating Modes - -| Mode | Behavior | -|------|----------| -| **Manual** | Agent analyzes and recommends. Human approves/rejects via dashboard. | -| **Suggest** | Agent recommends with confidence score. High-confidence decisions auto-execute. | -| **Autopilot** | Agent executes autonomously. All decisions logged for review. | - -### Revenue Management Agents - -| Agent | What It Does | -|-------|-------------| -| **Revenue Manager (RManager)** | The revenue **orchestrator**. Runs the levers below in dependency order (demand first, then pricing, overbooking, channel mix, group pickup) and reconciles them into one coherent strategy grounded in established RM doctrine: optimizes **GOPPAR** (profit per available room) over raw revenue, moves price with demand band and booking pace, protects peak dates with length-of-stay controls and zero overbooking, keeps the rate grid internally consistent, evaluates group displacement on net contribution, and treats discounting as a last resort. Surfaces conflicts between levers and projects RevPAR/GOPPAR across the horizon. | -| **Demand Forecasting** | Predicts future occupancy using weighted moving averages with day-of-week seasonality, booking pace, and last-minute demand signals. Heuristic model → statistical model progression. | -| **Dynamic Pricing** | Calculates optimal room rates based on demand tier, booking pace, lead-time decay, and weekend premiums. Enforces floor/ceiling rate constraints. | -| **Channel-Mix Optimization** | Ranks OTA channels by net revenue (gross × (1−commission) × (1−cancel_rate)). Recommends allocation shifts and stop-sell when occupancy exceeds thresholds. | -| **Overbooking Management** | Calculates optimal overbooking level using expected value optimization: (overbook revenue × fill probability) vs (walk cost × walk probability). Respects walk cost constraints. | - -### Operations Intelligence Agents - -| Agent | What It Does | -|-------|-------------| -| **Night Audit Anomaly Detection** | Scans checked-in reservations, folios, and closed cashier shifts for 8 anomaly types: unposted charges, missing tax, payment mismatches, stale check-ins, duplicate folios, no-show candidates, unusual charges, and cash-drawer variance outliers (z-score > 2.5 statistical outlier detection). Ranked by severity (critical/warning/info) and confidence. | -| **Housekeeping Optimization** | Builds workload-balanced cleaning schedules. Prioritizes VIP and early check-in rooms, groups by floor for route efficiency, estimates cleaning times by task type (checkout 30min, stayover 20min, deep clean 60min, suite 45min). | -| **Cancellation Prediction** | Scores every active reservation with a cancellation probability based on booking source (OTA 25% base vs direct 8%), deposit status, repeat guest history, VIP level, lead time, and days until arrival. Adds **deposit-forfeit risk** scoring on held deposits (likely-forfeit vs likely-refund exposure). Aggregates risk by date for overbooking decisions. | -| **A/R Collections Prioritization** | Ranks open Accounts Receivable ledgers by collection priority — weighing outstanding balance, days overdue beyond payment terms, and open transfer count — into low/medium/high risk tiers with a recommended action (monitor, send reminder, send final notice). | -| **Group Pickup Forecasting** | Projects final pickup vs. wash/attrition for each allotment block ahead of its cutoff date, using current pickup pace and historical pickup rate. Recommends hold / partial-release / full-release with a suggested release quantity to recover unsold inventory. | - -### Guest Engagement Agents - -| Agent | What It Does | -|-------|-------------| -| **Guest Communication** | Template-based lifecycle emails: confirmation, pre-arrival (3 days), day-of arrival, welcome (on check-in), post-stay, and win-back (90 days). **Event-driven drafts** on `reservation.created` / `checked_in` / `checked_out` via `GuestCommsListener`; scheduled types still from manual/cron `guest_comms/run`. Repeat vs first-time personalization. GDPR opt-out enforcement. Duplicate prevention via decision log. Configurable SMTP (defaults to draft-only). Outbound **SMS** and **WhatsApp** templates via Twilio providers (console fallback when not configured). | -| **Review Response** | Drafts professional responses to guest reviews entered by staff. Keyword-based topic extraction across 10 categories (cleanliness, staff, value, noise, food, wifi, etc.). Sentiment classification from rating (1-2 negative, 3 mixed, 4-5 positive). Three response styles (formal/friendly/casual). Matches guests to reservations for stay-specific references. Template-based assembly — no LLM freeform text, no hallucination risk. | - -### Decision Logging & per-property calibration - -Every agent decision is persisted (input snapshot, recommendation, confidence, outcome) -as an audit trail. On top of that, agents **calibrate to each property's own history**: -running `train()` recomputes an agent's parameters from that hotel's real outcomes and -stores them in `agent_configs.modelState`, which `analyze()` then uses instead of the -cold-start defaults. - -> **What this is — and isn't (read this).** The agents are **deterministic decision -> engines**, not LLMs — that's the point: they're the auditable guardrail. "Learning" here -> means **statistical calibration from your hotel's own data** (e.g. the cancellation agent -> learns this property's real cancel rates by booking source), not a neural net. Today the -> cancellation agent calibrates for real; the other history-rich agents follow the same -> pattern. The customer-comms and review-response agents are deterministic templates (no -> LLM, no hallucination) and do **not** "learn." - -#### What learns vs. what's deterministic - -| Agent | Today | -|---|---| -| Cancellation Predictor | **Calibrates** per-property cancel rates by source from history | -| Demand · Overbooking · Channel Mix | Deterministic; same calibration pattern is the roadmap | -| Pricing · Revenue Manager · Group Pickup · AR · Housekeeping · Night Audit | Deterministic math/rules | -| Guest Comms · Review Response | Deterministic templates (no LLM) | - -**HAIP AI (optional):** a small, purpose-built **local** model (served via Ollama) that -adds a plain-language *explanation + suggestions* layer over any agent decision — strictly -grounded in that agent's own numbers, with the deterministic agent vetoing any figure it -didn't compute (so it can't invent a rate, a policy, or a number). It **explains and -suggests; it never executes** — approval always runs the agent's own recommendation. It -runs entirely on the property's own hardware (no cloud calls, no per-use fees, and no guest -data leaves the building) and is **off by default** — the PMS works fully without it. - -Enable it: - -```bash -# pull the model (Apache-2.0, ~5 GB), then turn it on -ollama pull hf.co/telivity/haip-ai -export HAIP_AI_ENABLED=true -export HAIP_AI_MODEL=haip-ai -``` - ---- - -## Features - -### Shipped product slices - -Recent backlog deliveries, mapped to the feature sections below. Each slice is a mergeable vertical cut — API + dashboard + tests — not a rewrite of the domain. - -| # | Slice | Merged PR | What it does | -|---|-------|-----------|--------------| -| 1 | **Upsells / ancillaries** | [#174](https://github.com/telivityai/haip/pull/174) | Property services catalog, package components, attach extras to a stay, post on check-in / night audit, booking-engine extras step, optional pre-arrival upsell prompt. See **Stay Extras & Packages**. | -| 2 | **Money policy** | [#175](https://github.com/telivityai/haip/pull/175) | Cancellation policies on rate plans, shared cancel/no-show evaluator, deposit refund/forfeit/apply on cancel · no-show · check-in. See **Reservation Management** and **Accounting & Cashiering**. | -| 3 | **Front desk stay ops** | [#181](https://github.com/telivityai/haip/pull/181) | Arrivals / in-house queues, walk-in, in-house room move, registration card at check-in, operational notes at the desk. See **Reservation Management**. | -| 4 | **A/R & cashier polish** | [#180](https://github.com/telivityai/haip/pull/180) | List cash drawers/sessions, A/R ledger CRUD + aging UX, folio→A/R from folio detail, reverse-transfer picker. See **Accounting & Cashiering** and **Folio & Billing**. | -| 5 | **Commercial profiles** | [#180](https://github.com/telivityai/haip/pull/180) (also [#179](https://github.com/telivityai/haip/pull/179)) | Standing-account billing terms on group profiles; link A/R ledgers and negotiated rates; Commercial dashboard page. See **Groups & Commercial Profiles**. | -| 6 | **Distribution polish** | [#202](https://github.com/telivityai/haip/pull/202) | Channels dashboard: rate-parity grid + overrides, connection mapping editor, content-push error surfacing. See **Channel Manager**. | -| 7 | **Guest journey** | [#202](https://github.com/telivityai/haip/pull/202) | Advance pre-register API, reservation SMS via Twilio (or console in demo), loyalty number / VIP display polish. See **Reservation Management** and **Guest Profiles**. | -| 8 | **Rates depth** | [#183](https://github.com/telivityai/haip/pull/183) | Rate-plan restrictions CRUD (MinLOS / MaxLOS / CTA / CTD / stop-sell + day-of-week overrides); derived-rate create; effective-rate calculator fix; PMS `reservation.create` calls `assertSellable`. See **Rate Plans & Pricing**. | -| 9 | **Property ops** | [#202](https://github.com/telivityai/haip/pull/202) | Lost & found, room status discrepancy compute, service requests, door-lock credentials, HK checklist / VIP / forecast depth. See **Housekeeping** and **Room Management**. | -| 10 | **Room discrepancies** | [#205](https://github.com/telivityai/haip/pull/205) | Housekeeping occupancy observation, persisted discrepancy cases, resolve / dismiss, night-audit open-count. See **Room Management**. | -| 11 | **Demand capture** | [#205](https://github.com/telivityai/haip/pull/205) | Turnaway logging with reason codes + summary; non-deducting waitlist → offer → convert to reservation. See **Reservation Management**. | -| 12 | **Loyalty ledger** | [#205](https://github.com/telivityai/haip/pull/205) | Organization loyalty program, guest accounts, earn/burn/adjust transactions (folio rebate burn). See **Guest Profiles**. | -| 13 | **WhatsApp messaging** | [#205](https://github.com/telivityai/haip/pull/205) | Outbound WhatsApp templates via Twilio (console fallback in demo); marketing uses existing GDPR marketing consent. See **Guest Engagement Agents**. | -| 14 | **Folio inbound posting** | [#205](https://github.com/telivityai/haip/pull/205) | Property-scoped inbound charges (e.g. phone / minibar) to in-house folios with vendor transaction idempotency. See **Folio & Billing**. | -| 15 | **Booking deep links** | [#205](https://github.com/telivityai/haip/pull/205) | Direct-booking deep-link helper for marketing / metasearch landings; channel activation notes under `docs/channels/`. See **Direct Booking Engine**. | -| 16 | **Agent orchestration** | [#262](https://github.com/telivityai/haip/pull/262) | Agent dependency graph (`agent-graph.ts`), RManager lever order + `upstreamResults`, external cron via `scripts/cron/agent-runs.sh`, `GET .../graph` + `orchestration-performance`, Revenue UI graph / RManager runs. See **AI Agents** and [`docs/agents-orchestration.md`](./docs/agents-orchestration.md). | - -### Direct Booking Engine (commission-free) -- A **public, guest-facing booking API** (`/api/v1/booking-engine/*`) a hotel puts behind its own website — search → quote → book → pay → confirm — capturing direct reservations with **zero OTA commission**. -- Authenticated by a per-property **publishable key** (`x-booking-key`): property-scoped, low-trust (it ships in client-side HTML), and restricted to search/quote/book and read-or-cancel-own-confirmation — it can never enumerate other reservations or tenants. -- Reuses the existing availability, rate, tax, reservation, folio and payment engines — prices are **re-quoted server-side** (never trusts a client price), payments are classified as a **deposit** liability (KB §10.5), and `reservation.created` fires so the channel manager pushes updated availability everywhere. -- **Embeddable widget app** (`apps/booking`) plus a dashboard **Settings → Booking Engine** tab to generate/rotate keys, choose sellable room types & rates, set branding, and configure the deposit policy. -- **Deep links** — build guest-facing search/book URLs (property, dates, occupancy, optional room/rate preselect, click attribution) for the hotel’s own booking page. - -### Data Migration & Import -- A generic **CSV import** on-ramp (`/api/v1/import/*`) for hotels switching from another PMS: upload a CSV, map columns, **dry-run** to validate, then commit — with **per-row error reporting** (one bad row never aborts the batch). Importers for guests, room types and rate plans, trivially extensible to more entities. - -### Accounting Export -- Plain **CSV export** of the daily revenue journal and trial balance (`/api/v1/accounting-export/*`) to import into your own books (QuickBooks/Xero/spreadsheet) — no hosted connector required. - -### Reservation Management -- Full lifecycle state machine: `pending → confirmed → assigned → checked_in → stayover → due_out → checked_out` -- Real-time availability engine with room type inventory -- Room assignment with automatic status transitions -- **Front desk stay ops** — arrivals / in-house queues (multi-status filters), walk-in create→assign→check-in, in-house **room move** (`PATCH …/move-room`, respects `doNotMove`), operational notes at the desk and on reservation detail -- **Guest registration at check-in** — registration card fields + `registrationSigned`; required when the property has `guestRegistrationRequired` -- Group check-in (batch operations) -- Bulk actions across multiple reservations (check-in / check-out / cancel) with per-reservation success/error results -- Reservation notes with active-count tracking -- Guest messaging from a reservation (email / SMS / WhatsApp templates; GDPR marketing opt-out enforced) -- Advance **pre-register** fields on a reservation before arrival -- **Waitlist** — non-deducting entries (offer → convert to a real reservation when inventory allows) -- **Turnaways** — append-only denied / regret demand with reason codes and summary reporting -- Unassigned-reservation finder (confirmed/assigned reservations with no room) -- Batch reservation import with per-row error handling -- Express checkout -- **Money policy** — property cancellation policies (free-cancel window, penalty type, deposit handling) linked from rate plans; shared evaluator for PMS / Connect / booking engine; deposit settlement on cancel, no-show, and check-in auto-apply -- No-show and cancellation handling with policy enforcement (reservation "un-cancel" is intentionally unsupported — a payment-integrity hazard; create a new reservation instead) -- Every state transition fires a webhook event and is audit-logged - -### Stay Extras & Packages (Upsells) -- Property **services catalog** — sellable extras with charge type, price, posting rule (`once` / `per_night` / `on_consumption` / `included_in_rate`), and sell channels (`booking_engine` / `front_desk` / `pre_arrival`) -- **Package rate plans** can bundle catalog services as components -- Attach extras to a reservation from front desk or booking; price snapshot + status lifecycle -- **Posting** — check-in posts `once` / included lines; night audit posts `per_night` (idempotent); folio routing and tax apply as usual -- Booking engine + embeddable widget optional extras step; guest-comms can prompt pre-arrival upsells when enabled - -### Folio & Billing -- Guest folios, master folios, and city ledger accounts -- Charge posting with department codes and revenue categories -- Charge routing rules (e.g., room & tax to company, incidentals to guest) -- Charge reversal, transfer between folios, and city ledger transfer -- Folio settlement and close workflows -- Charge locking for night audit -- **Transfer folio balance to A/R** from folio detail (direct-bill handoff) -- **Inbound posting** — property-scoped webhook posts (e.g. phone / minibar) onto the in-house guest folio, idempotent by vendor transaction id - -### Split Folios & House Accounts -- **Split folio** — multiple folios per reservation with config-driven routing rules (e.g. room & tax → company folio, incidentals → guest folio) and the ability to move transactions between folios individually or by charge type (night-audit-locked charges are protected). -- **House accounts** — a non-guest ledger for walk-in retail, bar/restaurant, vendor, or internal sales not tied to any reservation. Open/close lifecycle, a product catalog for retail sales, and charge/payment posting on the same unified ledger as folios (keeps room vs. non-room revenue distinct in reports). -- **Correction matrix** — a payment-state-aware correction policy that picks the safe operation automatically: **void** uncaptured authorizations (and same-day cash), **refund** captured card payments, or post a compensating **adjustment** when neither applies. Illegal overrides (e.g. voiding a captured card) are rejected. - -### Groups & Commercial Profiles -- **Group profiles** — master records for corporate, travel-agent, wholesale, and event business, with an optional group (master) folio and computed group invoices. -- **Commercial standing accounts** — billing address + payment terms on group profiles; optional links from **A/R ledgers** and **negotiated rate plans** (`groupProfileId`); Commercial dashboard page lists corporate / travel-agent / wholesale accounts and can create a linked A/R ledger (`GET /groups/profiles/:id/commercial`). -- **Allotment blocks** — hold a quantity of rooms per date and room type at negotiated rates, with cutoff dates, shoulder dates, and Min/Max LOS. Inventory is validated against live availability so a block can't over-allot. -- **Cutoff & auto-release** — release unsold rooms back to general inventory at the cutoff date, per block or via a sweep endpoint that processes all expired auto-release blocks. -- **Pickup tracking** — rooms allotted vs. picked up, per date and room type, with pickup rate. -- **Rooming lists** — batch-import a group's guest roster; each row creates and links a reservation and increments pickup, with per-row success/error handling that never aborts the batch. - -### Accounting & Cashiering -- **Deposit Ledger** — advance deposits tracked as a liability (not revenue) with a full recognition lifecycle: `held → applied` (at check-in/checkout), `refunded`, or `forfeited`. Refundable vs. non-refundable handling, with status-transition guards. Cancel/no-show/check-in settlement follows the property money policy. -- **Accounts Receivable** — named A/R ledgers for post-stay direct billing; transfer an outstanding folio balance to A/R (zeroing the folio), record A/R payments, reverse transfers with a preserved audit trail, aging buckets (0–30 / 31–60 / 61–90 / 90+), and property-wide aging (`GET /ar/aging`). Dashboard create/close ledgers and reverse-transfer picker via `GET /ar/ledgers/:id/transactions`. -- **Cash Drawer & Cashiering** — per-drawer cash tracking with shift sessions, cash movements (payment, refund, paid-out, drop), shift close with expected-vs-counted **variance** detection, and a cashier's report. Dashboard lists drawers (`GET /cash/drawers`) and can resume open sessions (`GET /cash/sessions`) using the drawer starting float. -- **Daily Trial Balance** — reconciliation across the Deposit, Guest, and A/R ledgers (opening + activity = closing). -- **Custom Accounting Codes** — user-defined transaction and General Ledger (GL) codes for export to external accounting systems. - -### Rate Plans & Pricing -- BAR (Best Available Rate), derived rates, and negotiated rates -- Rate derivation: amount or percentage adjustments from parent plans -- Restrictions: MinLOS, MaxLOS, CTA (Closed to Arrival), CTD (Closed to Departure), stop-sell (`isClosed`), optional day-of-week overrides — dashboard detail panel + existing REST sub-resource -- Effective rate calculation (resolves derived parent chain); calculator UI passes `propertyId` and reads `effectiveRate` -- PMS reservation create enforces restrictions via `RatePlanService.assertSellable` (same gate as Connect / booking-engine BOOK) -- Occupancy-based rate adjustments - -### Room Management -- Room type configuration with amenities, max occupancy, and base rates -- Room status state machine: `vacant_clean → occupied → vacant_dirty → clean → inspected → guest_ready` -- Connecting room support -- ADA/accessible room tracking -- Real-time status summary dashboard -- Per-room photo and editable features/amenities from the room detail panel (primary image falls back to the room type's photo) -- **Discrepancy workflow** — housekeeping occupancy observation vs front-office in-house state; open cases with resolve / dismiss; open-count for night audit -- Optional door-lock credential issuance hooks for in-house stays - -### Media & Photos -- Image management for **properties, room types, and rooms** — a polymorphic `media` model with a denormalized `property_id` on every row for multi-tenant scoping -- Add images **by URL** (zero infra) or **upload files** to S3-compatible object storage (AWS S3 / MinIO) when configured — the driver is selected by env, so the default demo runs on stock URLs with no storage backend and no committed binaries -- Per-owner ordering, captions, alt text, and categories (hero, exterior, room, amenity, dining), with a single enforced **primary** image per owner (partial unique index) -- Dashboard photo galleries wired into Room Types, individual Rooms, and Property Settings — reorder, set-primary, and delete -- Image mutations are admin-gated; reads are available to any authenticated user - -### Guest Profiles -- Full guest profiles with contact, preferences, company, and stay history -- ID document fields captured at check-in (type, number, issuing country, expiry) and shown on the guest profile -- VIP level tracking (standard, silver, gold, platinum, diamond) -- Loyalty membership number on the guest profile -- **Loyalty points ledger** — organization-scoped program, guest accounts, earn / burn / adjust transactions (burn as folio rebate) -- Do Not Rent (DNR) flagging -- GDPR consent tracking (including marketing opt-in/out) and data retention controls -- Property setting `guestRegistrationRequired` — when on, check-in requires a signed registration card -- Guest search with flexible filters - -### Housekeeping -- Task CRUD with 6 task types: checkout clean, stayover, deep clean, inspection, turndown, maintenance -- Auto-task creation on checkout (event-driven via `room.status_changed`) -- Digital checklists with templates per task type -- ADA and VIP-aware checklist augmentation (automatic extra items) -- Staff assignment, auto-assignment (round-robin by floor/priority), and workload tracking -- Task lifecycle: `pending → assigned → in_progress → completed → inspected` -- Inspection pass/fail with automatic re-clean on failure -- Room status integration — completing a task transitions the room through `clean → inspected → guest_ready` -- Stayover task generation for occupied rooms -- **Lost & found** — held / returned / disposed items, including baggage / parcel / valet categories -- **Service requests** — maintenance and guest-service tickets linked to rooms / reservations -- Dashboard with room summary, task summary, housekeeper performance, and urgent rooms -- Analytics: average turn time, median turn time, inspection pass rate, maintenance issue rate, breakdown by room type and housekeeper - -### Night Audit & Reporting -- Automated night audit: room revenue posting, no-show processing, rate validation, day close -- AI anomaly detection: 8 anomaly types (incl. cash-drawer variance) with severity ranking and confidence scores -- Daily revenue reports with department breakdown -- Occupancy reports with ADR (Average Daily Rate) and RevPAR -- Financial summaries with revenue categories -- Occupancy trend analysis over date ranges -- KPI dashboard in the admin UI - -### Channel Manager -- ARI (Availability, Rates, Inventory) push to connected OTAs -- **Content distribution** — push descriptive content (photos, descriptions, amenities) to OTAs via each adapter's content API, with content-sync logging and **auto-resync** when property or media content changes (`property.content_updated` / `roomtype.content_updated` events) -- Channel connection management with credentials and mapping -- Inbound reservation processing from OTA channels -- Reservation pull from channels -- Rate parity monitoring and enforcement -- Rate override capabilities per channel -- Stop-sell functionality -- Sync logging for audit trails (ARI **and** content pushes) - -#### OTA Adapters - -| Adapter | Type | Coverage | -|---------|------|----------| -| **Booking.com** | Direct integration | OTA XML for ARI + inbound reservation webhooks/cancellations; JSON **Photo API** for content (photos with validation, plus room/property descriptions & amenities). | -| **Expedia (EQC)** | Direct integration | EQC Availability & Rates (XML) for ARI, Booking Notification push for inbound reservations, and the **Image API** for content (with Expedia's own image limits). | -| **SiteMinder** | Aggregator (pmsXchange) | SOAP / OTA XML. Connect once, distribute to 450+ OTAs — ARI push, reservation delivery, rate parity. (Content is managed in the SiteMinder extranet — no PMS content push.) | -| **DerbySoft** | Aggregator (Property Connector) | REST/JSON + OAuth Bearer. ARI (inventory/rate/availability) with Delta/Overlay, property profile sync, inbound LiveCheck/Book/Modify/Cancel. See [`docs/channels/derbysoft.md`](./docs/channels/derbysoft.md). | - -Operator notes for activating existing adapters, metasearch landings on the direct booking engine, and GDS via a channel manager: [`docs/channels/`](./docs/channels/). - -### Payments (pluggable gateways) -- PCI DSS compliant — never stores raw card data -- Pluggable payment gateway adapters — **Stripe** is the default full path (PaymentIntents, customer creation, tokenization); additional PSP adapters are available via the integration registry -- Authorization, capture, void, and refund workflows -- Payment recording with method tracking (card, cash, bank transfer) -- Linked to folio charges - -### Tax Calculation Engine -- Jurisdiction-based tax rules (state, city, county levels) -- Tax types: sales tax, occupancy/lodging tax, tourism tax, VAT -- Inclusive and exclusive tax calculation -- Rate-based and fixed-amount taxes -- Tax-exempt guest handling -- Automatic tax application on charge posting -- Tax breakdown on folio output - -### Authentication & Authorization (Keycloak) -- OAuth 2.0 / OpenID Connect via Keycloak identity provider -- JWT validation with RS256 public key verification -- Keycloak roles (`admin`, `front_desk`, `housekeeping`, `revenue_manager`) **plus HAIP's own local roles & permissions** (see *Users, Roles & Permissions* below) -- `@Roles()` and `@RequirePermissions()` decorators guard every controller -- `@Public()` decorator for unauthenticated endpoints (health checks) -- `@CurrentUser()` decorator for extracting authenticated user context - -### Users, Roles & Permissions (Admin Console) -- **Local identity & authorization** layered on top of Keycloak login — HAIP owns its own `users`, `roles`, `role_permissions`, and `user_roles` tables (property-scoped, multi-tenant) -- **Code-defined permission catalog** (e.g. `reservations.write`, `rooms.read`, `housekeeping.manage`, `channels.manage`, `media.manage`, `admin.users.manage`) mapped 1:1 to API capabilities and dashboard nav items -- **Custom roles** — operators create roles and grant granular permissions via a permission matrix; built-in system roles are protected from edits/deletion -- `PermissionsGuard` + `@RequirePermissions()` augment the Keycloak JWT guard; permissions drive both API authorization **and** which nav items/pages each user sees -- **Admin console** in Settings: a **Users** tab (create/invite users, assign roles) and a **Roles** tab (permission matrix) -- Works fully in the demo with `AUTH_ENABLED=false` (all permissions granted); binds to Keycloak subjects when auth is enabled - -### Webhook Engine -- Real-time webhook delivery on every entity state change -- 73 event types including accounting events (`deposit.received`, `ar.transfer_created`, `cashdrawer.session_closed`), house-account & folio events (`houseaccount.opened`, `folio.transactions_moved`, `payment.corrected`), fiscal document events (`invoice.requested`, `invoice.issued`, `invoice.voided`), group events (`group.block_created`, `group.rooming_list_imported`), reservation-ops events (`reservation.note_added`, `reservation.message_sent`, `reservation.bulk_action_completed`), and AI agent events (`agent.decision_made`, `agent.cancellation_forecast_updated`, `guest.communication_drafted`, `guest.review_response_drafted`) -- Event format: `entity.action` (e.g., `reservation.created`, `housekeeping.task_completed`) -- Subscription management for external consumers -- HMAC-signed deliveries with retries; permanent failures raise a critical staff notification -- See **[`docs/webhooks.md`](./docs/webhooks.md)** for the integration guide (signature verification, payload conventions, fiscal documents, regional compliance examples) - -### ChatGPT Gateway (Connect GPT) -- A standalone, deployable **gateway that exposes HAIP hotel search & booking as a ChatGPT Custom GPT Action** (`tools/haip-connect-gpt`) — guests search availability and create/modify/cancel reservations by chatting -- A thin, typed client over HAIP's existing **Connect API** (`/api/v1/connect/*`) — no hotel logic is reimplemented; it builds a ChatGPT-importable **OpenAPI 3.1** spec for 6 operations (`searchHotels`, `getProperty`, `create`/`get`/`modify`/`cancelReservation`) -- **Secure by design** — the gateway injects HAIP's API key server-side (the GPT never sees it), and response guards ensure **only selling prices** reach the model (net/wholesale/cost stripped) -- **PII-scrubbed tool-call logging** for training, with an optional Supabase/Postgres sink -- Host-agnostic — ships as a **Vercel** serverless function, a plain Node server, or a Docker container - -### Admin Dashboard -- React SPA including Dashboard, Front Desk, Reservations, Guests, Rooms, Housekeeping, Folios, Groups, **Commercial**, Cashier, House Accounts, Accounting, Tax, Rate Plans, Night Audit, Reports, Channel Manager, Revenue Management, Communications, Reviews, Settings -- Revenue Management page: KPI cards, pending AI recommendations with approve/reject, agent performance metrics, per-agent configuration -- Night Audit page: AI anomaly detection section with severity-coded alerts -- Communications page: email draft preview, send / approve / reject workflow, manual **Run guest-comms** for scheduled drafts, delivery stats -- Reviews page: add reviews, AI-drafted responses, edit/approve/mark-posted workflow, rating stats -- Settings page: property settings (incl. registration-card requirement), photo gallery, plus **Users & Roles administration** (user management + permission matrix) -- Reservations detail: operational notes + compose guest email (GDPR marketing flag enforced server-side) -- Rooms & Room Types: photo galleries with primary/reorder, per-room editable features -- Real-time updates via WebSocket (new reservations, room status changes, AI agent decisions) -- Responsive layout with mobile sidebar drawer -- Calendar view for reservations (day/week/month) -- Housekeeping kanban board -- KPI cards with trend indicators -- Skeleton loading states, toast notifications, error boundaries -- Served as static files in production (single Docker image) - ---- - -## Tech Stack - -| Layer | Technology | Purpose | -|-------|-----------|---------| -| Language | TypeScript (strict mode) | End-to-end type safety | -| Runtime | Node.js ≥ 20 | Server runtime | -| API Framework | NestJS | Modules, DI, decorators, OpenAPI generation | -| Frontend | React 19 + Vite | Admin dashboard SPA | -| UI State | TanStack Query + TanStack Router | Server state + file-based routing | -| Styling | Tailwind CSS 4 | Utility-first CSS | -| Database | PostgreSQL 16 | Multi-tenant relational storage | -| ORM | Drizzle ORM | TypeScript-native, no magic | -| Cache & Queue | Redis 7 + BullMQ | Caching, job queues, pub/sub | -| Real-time | Socket.IO (via NestJS Gateway) | WebSocket broadcasting per property | -| API Spec | OpenAPI 3.0 (auto-generated) | Swagger UI at `/docs` | -| Auth | Keycloak (OAuth 2.0 / OIDC) | Identity provider, JWT, RBAC | -| Payments | Pluggable gateways (Stripe default) | PCI DSS compliant tokenization; additional PSP adapters | -| OTA Channels | Booking.com + Expedia (EQC) + SiteMinder + DerbySoft | Direct + aggregated OTA connectivity (ARI + content) | -| XML Processing | fast-xml-parser | Booking.com OTA XML protocol | -| Package Manager | pnpm workspaces | Monorepo management | -| Testing | Vitest (1417 tests across 200 test files) | Unit and integration tests || Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds | -| Containers | Docker + docker-compose | Local dev and production deployment | -| CI/CD | GitHub Actions | Automated testing, builds, and releases | - ---- - -## Quick Start - -### 🚀 Try it in one command - -The only prerequisite is **Docker**. No Node, no pnpm, no API keys, no config. - -```bash -git clone https://github.com/TelivityAI/haip.git -cd haip -docker compose up -``` - -That's it. Compose builds the image, initializes the database, **seeds a full demo -hotel with the AI agents already running**, and serves everything at one URL: - -- **Dashboard:** `http://localhost:3000` -- **Swagger / OpenAPI:** `http://localhost:3000/docs` -- **Booking:** `http://localhost:3000/booking/` — live guest booking widget (search → quote → book) -- **Preview page:** `http://localhost:3000/booking-preview.html` — optional static walkthrough of every booking screen - -**Wire a shipped or adapter integration in one command** (API must be up): - -```bash -./integrations/demos/run.sh list shipped # 21 product paths -./integrations/demos/run.sh list adapters # 37 console fiscal/guest-reg packs -./integrations/demos/run.sh stripe # toggle ON + mock/console path -./integrations/demos/run.sh all shipped -``` - -Each pack has \`GO_LIVE.md\` for live credentials. Details: [\`integrations/demos/\`](./integrations/demos/). - -Dashboard UI: English and Deutsch (header language switcher). - -The first run builds the image and can take a few minutes; subsequent starts are -fast. A one-shot `init` container pushes the schema and seeds the demo before the -API starts (idempotent — safe to re-run). Auth is **off by default** so you land -straight in the app; to explore the Keycloak login/RBAC flow instead, run: - -```bash -docker compose -f docker-compose.yml -f docker-compose.auth.yml --profile auth up -d --build -``` - -See [`docs/deployment.md`](./docs/deployment.md) for production self-host. - -> One-click cloud demo: see [Deploy to the cloud](#deploy-to-the-cloud) to spin up -> a hosted instance with zero local setup. - -### Production self-host - -See **[`docs/deployment.md`](./docs/deployment.md)** for the full production guide (compose files, env vars, TLS, backups, upgrades, GHCR images). - -Quick start: - -```bash -cp .env.production.example .env.production -# Edit .env.production — set DATABASE_URL, Stripe keys, Keycloak, CONNECT_API_KEY, etc. - -docker compose -f docker-compose.yml -f docker-compose.prod.yml --profile auth up -d --build -``` - -Auth is on (`AUTH_ENABLED=true`); do not set `HAIP_ALLOW_INSECURE`. - -### Local development - -For hot-reload development against the source (requires **Node ≥ 20** and **pnpm ≥ 9**): - -```bash -git clone https://github.com/TelivityAI/haip.git -cd haip - -# Start only the infra (Postgres + Redis); add `keycloak` if testing auth -docker compose up -d postgres redis - -pnpm install -cp .env.example .env # defaults work as-is — no keys needed for a demo - -pnpm build # build workspace packages -pnpm db:migrate # push schema (same as --filter @telivityhaip/database run migrate) -pnpm seed # seed demo data - -pnpm dev # API with hot reload (:3000) -pnpm --filter @telivityhaip/dashboard dev # dashboard dev server (:5173) -pnpm --filter @telivityhaip/booking dev # booking widget dev server (:5174) -``` - -| Service | URL | -|---------|-----| -| API | `http://localhost:3000` | -| Dashboard (dev) | `http://localhost:5173` | -| Booking widget (dev) | `http://localhost:5174` | -| Keycloak (`--profile auth`) | `http://localhost:8080` | - -> **Local dev dashboard is `:5173`, not `:3000`.** The API at `:3000` serves Swagger -> and the REST API only unless you set `SERVE_DASHBOARD=true` on the API process. - -> Payments default to `STRIPE_MODE=mock` and guest email to draft-only, so no -> Stripe or SMTP credentials are required. Set real keys in `.env` only when you -> want live payments/email. - -### Deploy to the cloud - -One-click deploy a hosted demo (provisions Postgres + Redis, builds, migrates, and -seeds automatically) using the included [`render.yaml`](./render.yaml) blueprint: - -[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/TelivityAI/haip) - -Any Docker host works too — the [`apps/api/Dockerfile`](./apps/api/Dockerfile) -builds a single image serving the API, dashboard (`/`), and booking engine (`/booking/`) on port 3000. - -### Production checklist - -Before going live, verify the items in [`docs/deployment.md`](./docs/deployment.md#required-environment-variables) and [`docs/operations/cron.md`](./docs/operations/cron.md). - -### Troubleshooting - -| Symptom | Fix | -|---------|-----| -| API won't start locally | Is Postgres/Redis up? `docker compose up -d postgres redis` | -| Dashboard empty in local dev | You opened `:3000` instead of `:5173` | -| `migrate` script missing on api package | Use `@telivityhaip/database`, not api: `pnpm db:migrate` | -| Docker API unhealthy | Check init logs: `docker compose logs init` (migrate/seed may have failed) | -| Booking returns 401 with auth on | Copy the booking key from **Settings → Booking Engine** (or pass `?key=` in the URL) | - -### Run tests - -```bash -# All tests (1417 tests across 200 test files) - -# API tests only -pnpm --filter @telivityhaip/api test - -# Dashboard component tests -pnpm --filter @telivityhaip/dashboard test - -# Type checking -pnpm typecheck - -# Linting -pnpm lint -``` - ---- - -## Demo Hotel — Telivity Grand Hotel - -The seed script creates a fully configured demo property with realistic data: - -| Entity | Count | Details | -|--------|-------|---------| -| Property | 1 | Telivity Grand Hotel — 5-star, 40 rooms, Miami Beach, USD, America/New_York | -| Room Types | 4 | Standard King, Deluxe Ocean View, Junior Suite, Penthouse Suite | -| Rooms | 40 | Across 4 floors with real room numbers, mixed statuses, 2 ADA-accessible | -| Rate Plans | 5 | BAR per room type + a seasonal promotional rate, with LOS/weekend restrictions | -| Guests | 15 | Mix of VIP levels (none through diamond), international, loyalty tiers | -| Reservations | 23 | Past, in-house, arrivals, future, no-show, and cancelled | -| Folios | 16 | With charges (room, tax, minibar, spa) and payments | -| Housekeeping Tasks | 18 | Checkout cleans, stayovers, deep cleans, inspections with checklists | -| Tax Profiles | 4 | Miami Beach 13%, Barcelona IVA+tourist, Amsterdam BTW+tourist, Berlin split | -| **AI Agents** | **12** | **Enabled in suggest mode — incl. the Revenue Manager orchestrator** | -| **Agent Decisions** | **10** | **A live decision log: revenue strategy, pricing, forecast, overbooking…** | -| **Guest Reviews** | **5** | **Two with AI-drafted responses, the rest awaiting action** | - -Everything is populated on first boot, so the dashboard — including the AI layer -(Revenue Manager, agent decision log, review responses) — is alive immediately, -not empty. Use it to explore the API and dashboard right after `docker compose up`. - ---- - -## Project Structure - -``` -haip/ -├── apps/ -│ ├── api/ # NestJS API application -│ │ ├── src/ -│ │ │ ├── database/ # Drizzle connection + module -│ │ │ └── modules/ -│ │ │ ├── agent/ # AI Agent framework -│ │ │ │ ├── demand/ # Demand forecasting agent -│ │ │ │ ├── pricing/ # Dynamic pricing agent -│ │ │ │ ├── channel-mix/# Channel-mix optimization agent -│ │ │ │ ├── overbooking/# Overbooking management agent -│ │ │ │ ├── night-audit/# Night audit anomaly detection agent -│ │ │ │ ├── housekeeping/# Housekeeping optimization agent -│ │ │ │ ├── cancellation/# Cancellation prediction agent -│ │ │ │ ├── guest-comms/ # Guest communication agent + email service -│ │ │ │ ├── review-response/ # Review response agent -│ │ │ │ ├── training/ # Agent training/learning utilities -│ │ │ │ ├── interfaces/ # HaipAgent interface definition -│ │ │ │ └── dto/ # Agent config + decision DTOs -│ │ │ ├── admin/ # Local users, roles & permissions (admin console) -│ │ │ ├── auth/ # Keycloak JWT + RBAC + permission guards -│ │ │ ├── channel/ # Channel manager (ARI, content, rate parity) -│ │ │ │ └── adapters/ # OTA channel adapters -│ │ │ │ ├── booking-com/ # Booking.com (OTA XML + Photo API) -│ │ │ │ ├── expedia/ # Expedia EQC (AR XML + Image API) -│ │ │ │ ├── siteminder/ # SiteMinder pmsXchange (SOAP/OTA XML) -│ │ │ │ └── derbysoft/ # DerbySoft Property Connector (REST/JSON) -│ │ │ ├── connect/ # OTAIP agent API layer -│ │ │ ├── media/ # Images for property / room types / rooms (URL + S3) -│ │ │ ├── events/ # WebSocket gateway -│ │ │ ├── folio/ # Folios, charges, routing, city ledger -│ │ │ ├── guest/ # Guest profiles, VIP, preferences -│ │ │ ├── health/ # Health check -│ │ │ ├── housekeeping/ # Tasks, checklists, inspection, dashboard -│ │ │ ├── night-audit/ # Automated night audit + day close -│ │ │ ├── payment/ # Pluggable payment gateways (Stripe default) -│ │ │ ├── property/ # Multi-property configuration -│ │ │ ├── rate-plan/ # Rates, derivation, restrictions -│ │ │ ├── reports/ # Revenue, occupancy, financial reports -│ │ │ ├── reservation/ # Booking lifecycle + availability -│ │ │ ├── room/ # Room types, inventory, status machine -│ │ │ ├── tax/ # Tax calculation engine -│ │ │ └── webhook/ # Event dispatch engine -│ │ ├── Dockerfile # Multi-stage build (API + Dashboard) -│ │ └── tsconfig.json -│ └── dashboard/ # React admin dashboard -│ ├── src/ -│ │ ├── components/ # Layout, UI primitives, modals -│ │ ├── context/ # PropertyContext for multi-tenant -│ │ ├── hooks/ # useApi, useSocket, data hooks -│ │ ├── pages/ # 15 page components -│ │ └── lib/ # API client, utilities -│ ├── tailwind.config.ts -│ └── vite.config.ts -├── packages/ -│ ├── database/ # Drizzle ORM schema + migrations -│ │ └── src/schema/ # Table files (property, room, guest, agent, etc.) -│ └── shared/ # Shared types, enums, webhook events -├── tools/ -│ └── haip-connect-gpt/ # ChatGPT Custom GPT gateway over the Connect API (Vercel) -├── docker-compose.yml # PostgreSQL + Redis + Keycloak + API -├── CLAUDE.md # AI agent constitution -└── .env.example # Environment template -``` - ---- - -## API Reference - -All endpoints are prefixed with `/api/v1/` and documented via OpenAPI 3.0. Run the API and visit `http://localhost:3000/docs` for the interactive Swagger UI. - -### Core Endpoints (~167 total) - -
-AI Agents — 16 endpoints - -``` -GET /api/v1/agents/:propertyId # List all agents with status -GET /api/v1/agents/:propertyId/graph # Dependency graph (nodes + edges + status) -GET /api/v1/agents/:propertyId/orchestration-performance # All-agent metrics + RManager summary -GET /api/v1/agents/:propertyId/:agentType/config # Get agent configuration -PUT /api/v1/agents/:propertyId/:agentType/config # Update config (mode, enabled, threshold) -POST /api/v1/agents/:propertyId/:agentType/run # Run agent (?triggeredBy=manual|schedule) -POST /api/v1/agents/:propertyId/:agentType/train # Train one agent (writes modelState) -POST /api/v1/agents/:propertyId/train-all # Train every enabled agent -GET /api/v1/agents/:propertyId/:agentType/decisions # Decision history -POST /api/v1/agents/:propertyId/decisions/:id/approve # Approve recommendation -POST /api/v1/agents/:propertyId/decisions/:id/reject # Reject recommendation -POST /api/v1/agents/:propertyId/decisions/:id/explain # HAIP AI grounded explanation -GET /api/v1/agents/:propertyId/:agentType/performance # Performance metrics -POST /api/v1/agents/:propertyId/reviews # Submit guest review -GET /api/v1/agents/:propertyId/reviews # List reviews (filter by status/source) -PATCH /api/v1/agents/:propertyId/reviews/:id # Update review response -``` -
- -
-Reservations — 21 endpoints - -``` -POST /api/v1/reservations/search-availability # Real-time availability check -POST /api/v1/reservations/group-check-in # Batch check-in -POST /api/v1/reservations/bulk-action # Bulk check-in/out/cancel -GET /api/v1/reservations/unassigned # Find unassigned reservations -POST /api/v1/reservations/import # Batch import reservations -POST /api/v1/reservations # Create reservation -GET /api/v1/reservations # List (filtered, paginated) -GET /api/v1/reservations/:id # Get with guest/room/rate -PATCH /api/v1/reservations/:id # Modify dates/room/rate -PATCH /api/v1/reservations/:id/confirm # Confirm -PATCH /api/v1/reservations/:id/assign-room # Assign room -PATCH /api/v1/reservations/:id/check-in # Check in -PATCH /api/v1/reservations/:id/check-out # Check out -POST /api/v1/reservations/:id/express-checkout # Express checkout -PATCH /api/v1/reservations/:id/cancel # Cancel -PATCH /api/v1/reservations/:id/no-show # Mark no-show -POST /api/v1/reservations/:id/notes # Add a note -GET /api/v1/reservations/:id/notes # List notes (with active count) -PATCH /api/v1/reservations/notes/:noteId # Update a note -DELETE /api/v1/reservations/notes/:noteId # Delete a note -POST /api/v1/reservations/:id/messages # Send a message to the guest -``` -
- -
-Folios & Billing — 12 endpoints - -``` -POST /api/v1/folios # Create folio -GET /api/v1/folios # List with filters -GET /api/v1/folios/:id # Get folio -PATCH /api/v1/folios/:id # Update folio -PATCH /api/v1/folios/:id/settle # Settle folio -PATCH /api/v1/folios/:id/close # Close folio -POST /api/v1/folios/:id/charges # Post charge -GET /api/v1/folios/:id/charges # List charges -POST /api/v1/folios/:id/charges/:chargeId/reverse # Reverse charge -POST /api/v1/folios/:id/charges/lock # Lock charges -POST /api/v1/folios/:id/transfer-charge # Transfer charge -POST /api/v1/folios/:id/transfer-to-city-ledger # Transfer to city ledger -POST /api/v1/folios/routing-rules # Create a split-folio routing rule -GET /api/v1/folios/routing-rules # List routing rules (by reservation) -POST /api/v1/folios/:id/move-transactions # Move charges between folios -``` -
- -
-House Accounts & Products — 11 endpoints - -``` -POST /api/v1/products # Create retail product -GET /api/v1/products # List products -GET /api/v1/products/:id # Get product -PATCH /api/v1/products/:id # Update product -POST /api/v1/house-accounts # Open a house account -GET /api/v1/house-accounts # List house accounts -GET /api/v1/house-accounts/:id # Get house account -POST /api/v1/house-accounts/:id/close # Close house account -POST /api/v1/house-accounts/:id/charges # Post a charge -POST /api/v1/house-accounts/:id/payments # Record a payment -POST /api/v1/house-accounts/:id/sell # Sell a product (retail) -``` -
- -
-Groups & Allotment — 16 endpoints - -``` -# Group Profiles -POST /api/v1/groups/profiles # Create group profile -GET /api/v1/groups/profiles # List group profiles -GET /api/v1/groups/profiles/:id # Get group profile -GET /api/v1/groups/profiles/:id/commercial # Commercial links (A/R + rates) -PATCH /api/v1/groups/profiles/:id # Update group profile -POST /api/v1/groups/profiles/:id/reservations # Link a reservation to the group -GET /api/v1/groups/profiles/:id/folio # Get the group (master) folio -POST /api/v1/groups/profiles/:id/invoice # Generate a group invoice -# Allotment Blocks -POST /api/v1/groups/blocks # Create allotment block -GET /api/v1/groups/blocks # List blocks -POST /api/v1/groups/blocks/process-cutoffs # Release all expired auto-release blocks -GET /api/v1/groups/blocks/:id # Get block -PATCH /api/v1/groups/blocks/:id # Update block -PUT /api/v1/groups/blocks/:id/inventory # Set per-date/room-type allotment -GET /api/v1/groups/blocks/:id/pickup # Pickup vs. allotted -POST /api/v1/groups/blocks/:id/release # Release block (free unsold rooms) -POST /api/v1/groups/blocks/:id/rooming-list # Import rooming list -``` -
- -
-Accounting — Deposits, A/R & GL Codes — 20 endpoints - -``` -# Deposit Ledger -POST /api/v1/deposits # Record a deposit (held liability) -GET /api/v1/deposits # List deposits (filtered) -GET /api/v1/deposits/:id # Get deposit -POST /api/v1/deposits/:id/apply # Apply deposit to folio (recognize) -POST /api/v1/deposits/:id/refund # Refund a refundable deposit -POST /api/v1/deposits/:id/forfeit # Forfeit (non-refundable → revenue) -# Accounts Receivable -POST /api/v1/ar/ledgers # Create A/R ledger -GET /api/v1/ar/ledgers # List A/R ledgers -GET /api/v1/ar/ledgers/:id # Get A/R ledger -PATCH /api/v1/ar/ledgers/:id # Update A/R ledger -POST /api/v1/ar/ledgers/:id/close # Close A/R ledger -POST /api/v1/ar/transfer # Transfer folio balance to A/R (zero folio) -POST /api/v1/ar/transactions/:id/reverse # Reverse a transfer (audit-safe) -POST /api/v1/ar/ledgers/:id/payments # Record an A/R payment -GET /api/v1/ar/aging # Property-wide aging buckets -GET /api/v1/ar/ledgers/:id/aging # Aging buckets (0–30/31–60/61–90/90+) -GET /api/v1/ar/ledgers/:id/transactions # List A/R ledger transactions -# Accounting Codes -POST /api/v1/accounting/codes # Create transaction/GL code -GET /api/v1/accounting/codes # List codes -GET /api/v1/accounting/codes/:id # Get code -PATCH /api/v1/accounting/codes/:id # Update code -POST /api/v1/accounting/codes/:id/archive # Archive code -``` -
- -
-Cashier — Cash Drawer — 7 endpoints - -``` -POST /api/v1/cash/drawers # Create cash drawer -GET /api/v1/cash/drawers # List drawers for property -GET /api/v1/cash/drawers/:id # Get drawer -POST /api/v1/cash/sessions # Open a cashier shift session -GET /api/v1/cash/sessions # List sessions (filter: drawer, status) -GET /api/v1/cash/sessions/:id # Get session -POST /api/v1/cash/sessions/:id/movements # Record cash movement -POST /api/v1/cash/sessions/:id/close # Close shift (variance check) -GET /api/v1/cash/sessions/:id/report # Cashier's report -``` -
- -
-Housekeeping — 15 endpoints - -``` -GET /api/v1/housekeeping/dashboard # Room + task + staff summary -GET /api/v1/housekeeping/analytics # Turn times, pass rates, trends -POST /api/v1/housekeeping/generate-stayover-tasks # Generate daily stayover tasks -POST /api/v1/housekeeping/auto-assign # Round-robin task assignment -POST /api/v1/housekeeping/tasks # Create task -GET /api/v1/housekeeping/tasks # List tasks (filtered) -GET /api/v1/housekeeping/tasks/:id # Get task -PATCH /api/v1/housekeeping/tasks/:id # Update task -DELETE /api/v1/housekeeping/tasks/:id # Delete task -PATCH /api/v1/housekeeping/tasks/:id/assign # Assign to housekeeper -PATCH /api/v1/housekeeping/tasks/:id/start # Start task -PATCH /api/v1/housekeeping/tasks/:id/unassign # Unassign task -PATCH /api/v1/housekeeping/tasks/:id/complete # Complete with checklist -PATCH /api/v1/housekeeping/tasks/:id/inspect # Inspect (pass/fail) -PATCH /api/v1/housekeeping/tasks/:id/skip # Skip task -``` -
- -
-Channel Manager — 17 endpoints - -``` -POST /api/v1/channels/connections # Create channel connection -GET /api/v1/channels/connections # List connections -GET /api/v1/channels/connections/:id # Get connection -PATCH /api/v1/channels/connections/:id # Update connection -DELETE /api/v1/channels/connections/:id # Deactivate connection -POST /api/v1/channels/connections/:id/test # Test connection -POST /api/v1/channels/push/availability # Push availability to OTAs -POST /api/v1/channels/push/rates # Push rates/restrictions -POST /api/v1/channels/push/full # Push full ARI -POST /api/v1/channels/push/stop-sell # Push stop-sell -GET /api/v1/channels/sync-logs/:connectionId # Get sync logs -POST /api/v1/channels/inbound/reservation # Process OTA reservation -POST /api/v1/channels/inbound/pull # Pull reservations -GET /api/v1/channels/rate-parity # Check rate parity -GET /api/v1/channels/rate-parity/effective-rate # Get effective rate -POST /api/v1/channels/rate-parity/override # Set rate override -DELETE /api/v1/channels/rate-parity/override # Remove override -``` -
- -
-Tax Calculation — 4 endpoints - -``` -POST /api/v1/tax/calculate # Calculate taxes for a charge -GET /api/v1/tax/rules # List tax rules for property -POST /api/v1/tax/rules # Create tax rule -PATCH /api/v1/tax/rules/:id # Update tax rule -``` -
- -
-Guests, Rooms, Rate Plans, Payments, Reports, Night Audit, Properties - -``` -# Guests — 5 endpoints -POST /api/v1/guests # Create guest -GET /api/v1/guests # Search guests -GET /api/v1/guests/:id # Get guest -PATCH /api/v1/guests/:id # Update guest -DELETE /api/v1/guests/:id # Delete guest - -# Rooms — 10 endpoints -POST /api/v1/rooms/types # Create room type -GET /api/v1/rooms/types # List room types -GET /api/v1/rooms/types/:id # Get room type -GET /api/v1/rooms/status-summary # Status summary counts -GET /api/v1/rooms/by-status # Rooms filtered by status -POST /api/v1/rooms # Create room -GET /api/v1/rooms # List rooms -GET /api/v1/rooms/:id # Get room -PATCH /api/v1/rooms/:id # Update room -PATCH /api/v1/rooms/:id/status # Transition room status - -# Rate Plans — 9 endpoints -POST /api/v1/rate-plans # Create rate plan -GET /api/v1/rate-plans # List rate plans -GET /api/v1/rate-plans/:id # Get rate plan -GET /api/v1/rate-plans/:id/effective-rate # Calculate effective rate -PATCH /api/v1/rate-plans/:id # Update rate plan -GET /api/v1/rate-plans/:id/restrictions # Get restrictions -POST /api/v1/rate-plans/:id/restrictions # Create restriction -PATCH /api/v1/rate-plans/:id/restrictions/:rid # Update restriction -DELETE /api/v1/rate-plans/:id/restrictions/:rid # Delete restriction - -# Payments — 7 endpoints -POST /api/v1/payments # Record payment -POST /api/v1/payments/authorize # Authorize card -GET /api/v1/payments # List payments -GET /api/v1/payments/:id # Get payment -POST /api/v1/payments/:id/capture # Capture authorized -POST /api/v1/payments/:id/void # Void payment -POST /api/v1/payments/:id/refund # Refund payment -POST /api/v1/payments/:id/correct # Correct payment (void/refund/adjust) - -# Reports — 5 endpoints -GET /api/v1/reports/daily-revenue # Daily revenue report -GET /api/v1/reports/trial-balance # Daily trial balance (deposit/guest/A-R) -GET /api/v1/reports/occupancy # Occupancy report -GET /api/v1/reports/financial-summary # Financial summary -GET /api/v1/reports/occupancy-trend # Occupancy trend - -# Night Audit — 3 endpoints -POST /api/v1/night-audit/run # Execute night audit -GET /api/v1/night-audit/runs # List audit runs -GET /api/v1/night-audit/runs/:id # Get audit run - -# Properties — 4 endpoints -POST /api/v1/properties # Create property -GET /api/v1/properties # List properties -GET /api/v1/properties/:id # Get property -PATCH /api/v1/properties/:id # Update property -``` -
- ---- - -## OTAIP Integration — Connect API - -The Connect API (`/api/v1/connect/`) is purpose-built for [OTAIP](https://github.com/telivity-otaip/otaip) AI agents. It provides higher-level abstractions over the core PMS endpoints, designed for agent workflows. - -### Agent Endpoints - -``` -# Property Search (Agent 4.1 — Hotel Search) -POST /api/v1/connect/search # Search properties by criteria -GET /api/v1/connect/properties # List all properties -GET /api/v1/connect/properties/:id # Full property detail - -# Booking (Agent 4.5 — Booking) -POST /api/v1/connect/book # Book a room -GET /api/v1/connect/bookings/:confirmationNumber/verify # Verify booking -PATCH /api/v1/connect/bookings/:confirmationNumber # Modify booking -DELETE /api/v1/connect/bookings/:confirmationNumber # Cancel booking - -# Event Subscriptions -POST /api/v1/connect/subscriptions # Subscribe to events -GET /api/v1/connect/subscriptions # List subscriptions -DELETE /api/v1/connect/subscriptions/:id # Unsubscribe -POST /api/v1/connect/subscriptions/:id/test # Test subscription -GET /api/v1/connect/events # Poll for events - -# AI Insights -GET /api/v1/connect/insights/revenue # Revenue optimization hints -GET /api/v1/connect/insights/guest-triggers # Guest communication triggers -GET /api/v1/connect/insights/housekeeping # Housekeeping optimization -``` - -### How OTAIP Connects - -OTAIP's `packages/connect` will have a HAIP adapter (like `AmadeusAdapter`, `DuffelAdapter`). Lodging agents (Domain 4) connect to HAIP via this adapter. Both projects share TypeScript, pnpm, Vitest, and strict TS config. - -``` -OTAIP Agent → ConnectAdapter (HAIP) → Connect API → HAIP PMS → PostgreSQL -``` - -The PMS is the product. OTAIP agents are the intelligence on top. - -> **ChatGPT too** — `tools/haip-connect-gpt` wraps this same Connect API as a ChatGPT Custom GPT Action (OpenAPI 3.1, server-side key injection, selling-price-only responses), so a guest can search and book straight from ChatGPT. See [`tools/haip-connect-gpt/README.md`](./tools/haip-connect-gpt/README.md). - ---- - -## WebSocket — Real-time Events - -HAIP uses Socket.IO for real-time event broadcasting to the dashboard and other connected clients. - -### Connection - -```typescript -import { io } from 'socket.io-client'; - -const socket = io('http://localhost:3000'); - -// Join a property room to receive its events -socket.emit('joinProperty', { propertyId: 'uuid-here' }); - -// Listen for events -socket.on('reservation.created', (data) => { /* ... */ }); -socket.on('room.status_changed', (data) => { /* ... */ }); -socket.on('housekeeping.task_completed', (data) => { /* ... */ }); -socket.on('agent.decision_made', (data) => { /* ... */ }); -``` - -### Broadcast Events - -All webhook events are simultaneously broadcast via WebSocket to clients subscribed to the relevant property. Events follow the `entity.action` naming convention: - -- `reservation.created`, `reservation.checked_in`, `reservation.checked_out` -- `room.status_changed` -- `housekeeping.task_assigned`, `housekeeping.task_completed`, `housekeeping.ai_assigned` -- `folio.charge_posted` -- `channel.sync_completed` -- `agent.decision_made`, `agent.cancellation_forecast_updated` -- `guest.communication_drafted`, `guest.communication_sent`, `guest.review_response_drafted` - ---- - -## Security & Compliance (Built In, Not Bolted On) - -HAIP is multi-tenant by construction, and isolation is enforced **in depth** — at the request boundary *and* the data layer — so a bug in one place isn't a breach. - -| Area | How HAIP Handles It | -|------|-------------------| -| **Tenant isolation** | `property_id` on every table. A global guard binds each authenticated request to the `propertyId` it targets (fail-closed), and every property-scoped query independently filters by `propertyId`. Caller-supplied foreign keys are verified to belong to the tenant before any write. | -| **Authentication & authorization** | Keycloak OIDC (RS256 JWT) **plus** HAIP's own local users/roles/permissions; `@Roles` + `@RequirePermissions` guards on every endpoint. The Connect (agent) API uses per-property API credentials stored as hashes; inbound OTA webhooks are authenticated per connection (Basic-Auth / HMAC). | -| **Input & transport hardening** | Strict DTO validation (incl. positive-amount checks on monetary fields and UUID-validated ids), an origin **CORS allowlist**, security response headers, **SSRF protection** on outbound webhooks, and rate limiting. The API **refuses to boot with insecure defaults in production** (auth off / mock payments) unless explicitly opted in for the demo. | -| **PCI DSS** | Never stores raw card data. Stripe tokenization via PaymentIntents. Payments table stores token + last four + brand. | -| **GDPR** | Audit trail (with actor) on every data modification, consent tracking fields, data retention and right-to-erasure APIs. | -| **Guest Registration** | Configurable per jurisdiction. ID verification fields. EU police reporting interface planned. | -| **Tax Calculation** | Jurisdiction-based tax engine. Inclusive/exclusive handling. Sales, occupancy, tourism, and VAT tax types. Per-property rules with exemption support. | - -> Security is treated as ongoing work: the codebase is reviewed continuously and hardened as it matures. Before production use, pair it with your own deployment review and a Keycloak configured to issue the `property_ids` claim. - ---- - -## Integrations - -HAIP is **API-first**: the same REST surface that powers the dashboard is documented via auto-generated **OpenAPI 3.0** at `/docs`, state changes fan out through **HMAC-signed webhooks**, and external systems connect through **pluggable adapters** (channels, payments, messaging, accounting, and more). - -See **[`docs/INTEGRATIONS.md`](./docs/INTEGRATIONS.md)** for the full integration catalog (~230 integrations across 19 categories). To wire your stack, start with **[`docs/webhooks.md`](./docs/webhooks.md)** (signature verification, payloads, retries) and the recipes under **[`docs/integrations/`](./docs/integrations/)**. **Fiscalization and guest registration** are first-class categories — so properties can plug in invoice issuance and authority reporting without custom one-offs. - ---- - -## Community & Support - -Discord is our main support channel. Questions, bugs, feature ideas, or just -want to talk to the team? Join us: - -👉 https://discord.gg/rJQJ5Rh5kH - -For confirmed bugs and feature requests, please still open a GitHub issue so we -can track it. For everything else, Discord is the fastest way to reach us. - ---- - -## Contributing - -HAIP is built in public and contributions are welcome. - -### The One Rule - -**DO NOT INVENT HOTEL DOMAIN LOGIC.** All hotel domain knowledge must be researched and verified. Do not invent hotel operations logic. If unsure, open an issue. Don't guess — hotels are weirder than you think. - -### How to contribute - -1. Check the [open issues](https://github.com/TelivityAI/haip/issues) -2. Read `CLAUDE.md` for code standards and conventions -3. Read the relevant KB section before writing business logic -4. Fork, branch, PR — tests required for all business logic -5. One module at a time. Don't boil the ocean. - -### Development commands +Before going live, verify [`docs/deployment.md`](./docs/deployment.md) and [`docs/operations/cron.md`](./docs/operations/cron.md). Run the operator harden pack ([`ops/harden/`](./ops/harden/)): ```bash -pnpm install # Install dependencies -pnpm build # Build all workspace packages -pnpm dev # Start API in dev mode (hot reload) -pnpm test # Run all tests (1417 tests, 200 files) -pnpm lint # ESLint +pnpm harden:local +pnpm harden:live ``` ---- - -## License - -Licensed under the [Apache License, Version 2.0](LICENSE). - -Copyright 2026 Telivity. You may use, modify, and distribute this software under the terms of the Apache 2.0 license. See the [LICENSE](LICENSE) file for the full text. - ---- - -

- Built by Telivity — open-source travel infrastructure for the AI era. -

+See also [`ops/harden/PRODUCTION.md`](./ops/harden/PRODUCTION.md). diff --git a/ops/harden/.env.harden.example b/ops/harden/.env.harden.example new file mode 100644 index 0000000..cb029b4 --- /dev/null +++ b/ops/harden/.env.harden.example @@ -0,0 +1,28 @@ +# HAIP operator harden — live probe env +# +# cp ops/harden/.env.harden.example .env.harden +# # edit values, then: +# set -a && source .env.harden && set +a +# pnpm harden:live +# +# Do NOT commit .env.harden — it contains bearer tokens. + +# API base ending in /api (compose default shown) +HAIP_API_BASE=http://localhost:3000/api + +# Keycloak bearer JWTs (property_ids claim must match the property below) +TOKEN_A= +TOKEN_B= + +# Property UUIDs +PROPERTY_A= +PROPERTY_B= + +# Optional: reservation that belongs only to property B +# RESERVATION_IN_B= + +# Optional: JWT missing property_ids or from wrong issuer (expect 401) +# TOKEN_BAD= + +# Optional: override request timeout ms (default 15000) +# HARDEN_TIMEOUT_MS=15000 diff --git a/ops/harden/CHECKLIST.md b/ops/harden/CHECKLIST.md new file mode 100644 index 0000000..6966506 --- /dev/null +++ b/ops/harden/CHECKLIST.md @@ -0,0 +1,56 @@ +# Production go-live checklist + +Use this before exposing HAIP to real guests or chargeable traffic. +Details: [`docs/deployment.md`](../../docs/deployment.md) and +[`.env.production.example`](../../.env.production.example). + +## Compose & boot + +- [ ] Copied `.env.production.example` → `.env.production` and filled secrets +- [ ] Started with prod overlay + auth profile: + ```bash + docker compose -f docker-compose.yml -f docker-compose.prod.yml --profile auth up -d --build + ``` +- [ ] `AUTH_ENABLED=true` (required in production) +- [ ] `HAIP_ALLOW_INSECURE` is **unset / empty** (never `true` in production) +- [ ] `STRIPE_MODE` is `test` until ready for real charges; then `live` with live keys +- [ ] API boots cleanly; `GET /api/v1/health` returns `status: ok` + +## Auth (Keycloak) + +- [ ] Keycloak runs in production mode with TLS (not `start-dev` exposed publicly) +- [ ] Strong Keycloak admin credentials (changed from defaults) +- [ ] Realm `haip` (or your realm) issues JWTs with `property_ids` and `roles` +- [ ] Unauthenticated `GET /api/v1/reservations?propertyId=` → **401** +- [ ] Dashboard built / served with auth on (`VITE_AUTH_ENABLED=true` in prod overlay) + +## Multi-tenancy + +- [ ] At least two test properties exist +- [ ] User A JWT has only property A; user B only property B +- [ ] `pnpm harden:live` passes tenant-isolation probes (see [`TENANT_ISOLATION.md`](./TENANT_ISOLATION.md)) +- [ ] SPA routes always include `propertyId` on detail pages + +## Payments & Connect + +- [ ] Stripe secret + webhook secret match `STRIPE_MODE` +- [ ] Stripe webhook endpoint receives events (signature verified) +- [ ] `CONNECT_API_KEY` set when auth is on (OTAIP / Connect agents) +- [ ] Booking engine keys generated under Settings → Booking Engine (when auth on) + +## Network & ops + +- [ ] TLS terminated at reverse proxy (Caddy/nginx/etc.) +- [ ] `CORS_ORIGINS` set if browser origin ≠ API host +- [ ] Night audit / group cutoff cron configured ([`docs/operations/cron.md`](../../docs/operations/cron.md)) +- [ ] Database backup + restore tested once +- [ ] Rate limiting considered for public origins + +## Product smoke (manual) + +- [ ] [`SURFACE_SMOKE.md`](./SURFACE_SMOKE.md) completed on property A — zero Critical on check-in, rooms, folios, night audit, housekeeping +- [ ] At least three desk [`vignettes/`](./vignettes/) exercised (recommend `base-01`, `base-07`, `base-15`) + +## Gate + +Do **not** put real hotel traffic on the instance while Critical surface failures or failed tenant-isolation probes remain open. diff --git a/ops/harden/PRODUCTION.md b/ops/harden/PRODUCTION.md new file mode 100644 index 0000000..7281b8f --- /dev/null +++ b/ops/harden/PRODUCTION.md @@ -0,0 +1,10 @@ +# Harden pack production pointer + +Before going live, run: + +```bash +pnpm harden:local +pnpm harden:live +``` + +See [`ops/harden/`](./) and the root README Production checklist. diff --git a/ops/harden/README.md b/ops/harden/README.md new file mode 100644 index 0000000..22ac129 --- /dev/null +++ b/ops/harden/README.md @@ -0,0 +1,79 @@ +# HAIP operator harden pack + +Playbooks and a small CLI so operators can harden a **self-hosted HAIP** +deployment (Keycloak + docker compose) before go-live and against a live URL. + +## What this is + +| Piece | Purpose | +|-------|---------| +| [`CHECKLIST.md`](./CHECKLIST.md) | Production go-live checklist | +| [`TENANT_ISOLATION.md`](./TENANT_ISOLATION.md) | Cross-tenant deny criteria (Keycloak JWTs) | +| [`SURFACE_SMOKE.md`](./SURFACE_SMOKE.md) | Full SPA surface walk before chargeable use | +| [`vignettes/`](./vignettes/) | Desk ops stories (guest → staff → delight/block) | +| [`cli/`](./cli/) | `harden:local` and `harden:live` HTTP probes | + +## Quick start + +### Pre-go-live (local / compose) + +From the HAIP repo root: + +```bash +cp .env.production.example .env.production +# Edit .env.production — AUTH_ENABLED=true, Stripe, CONNECT_API_KEY, etc. + +docker compose -f docker-compose.yml -f docker-compose.prod.yml --profile auth up -d --build + +pnpm harden:local +``` + +Then walk [`SURFACE_SMOKE.md`](./SURFACE_SMOKE.md) and a few [`vignettes/`](./vignettes/) against the dashboard. + +### Live instance + +```bash +cp ops/harden/.env.harden.example .env.harden +# Fill HAIP_API_BASE, TOKEN_A, TOKEN_B, PROPERTY_A, PROPERTY_B + +set -a && source .env.harden && set +a +pnpm harden:live +``` + +Exit code `0` = all probes passed; non-zero = at least one fail. See the printed table. + +## Keycloak tokens + +Tokens must be JWTs issued by your Keycloak realm (`haip` by default) for the +API client (`haip-api`). Claims the API expects: + +| Claim | Meaning | +|-------|---------| +| `property_ids` | Array of property UUIDs this user may access | +| `roles` | HAIP roles (e.g. `admin`, `front_desk`, `readonly`) | + +**User A** should have `property_ids=[PROPERTY_A]` only. +**User B** should have `property_ids=[PROPERTY_B]` only. + +How you mint tokens is up to your deployment — the CLI only needs the bearer strings. + +`HAIP_API_BASE` is the API origin **including** `/api` (compose default: `http://localhost:3000/api`). +Probes call `{HAIP_API_BASE}/v1/...`. + +## Modes + +| Command | Needs running stack? | Needs tokens? | +|---------|----------------------|---------------| +| `pnpm harden:local` | Preferred (for HTTP checks); file checks always run | Optional | +| `pnpm harden:live` | Yes (your URL) | **Required** | + +## Manual ops (after CLI green) + +1. Complete [`SURFACE_SMOKE.md`](./SURFACE_SMOKE.md) on property A. +2. Run several vignettes from [`vignettes/`](./vignettes/) (start with `base-01`, `base-07`, `base-15`). +3. Re-run `pnpm harden:live` after any auth or multi-tenancy change. + +## Non-goals + +- Automated full-SPA Playwright campaigns (use surface smoke + vignettes manually in v1) +- Exhaustive penetration testing — this pack is an operator readiness gate, not a red-team suite diff --git a/ops/harden/SURFACE_SMOKE.md b/ops/harden/SURFACE_SMOKE.md new file mode 100644 index 0000000..d473aa5 --- /dev/null +++ b/ops/harden/SURFACE_SMOKE.md @@ -0,0 +1,61 @@ +# Full-surface smoke (self-hosted HAIP) + +Run this on every candidate production (or staging twin) deploy before putting +real guests or chargeable traffic on the instance. Desk vignettes alone are +**not** enough. + +## Environment + +- Dashboard served by your HAIP API (compose: `http://localhost:3000`) +- Sign in via Keycloak with a staff/admin user for **property A** +- Always open routes with `?propertyId=` +- Never put passwords in notes or screenshots you share + +## Rule + +Click the real control. If nothing happens → **BROKEN**. +Pretty empty page with no path to do the job → **EMPTY_SHELL**. +Nav that lies / no feedback after a critical action → **CONFUSING**. + +Classifications: `BROKEN` | `EMPTY_SHELL` | `CONFUSING` | `PARTIAL` | `WORKS` + +## Checklist (engine SPA — no skipping) + +| # | Surface | Route | Must prove | +|---|---------|-------|------------| +| 1 | Dashboard | `/` | Loads for the property; not a dead shell | +| 2 | Check-in / Front desk | `/front-desk` | Confirm check-in completes; walk-in path works; clear errors | +| 3 | Reservations | `/reservations` | List + open a reservation; create/modify path clear | +| 4 | Guests | `/guests` | Profile useful mid-shift (stay context / notes — not name-only) | +| 5 | Rooms status | `/rooms` | Change status sticks + visible feedback | +| 6 | Room types | `/rooms/types` | Types list/edit operable | +| 7 | Housekeeping | `/housekeeping` | Tasks load (or clear actionable empty + generate works) | +| 8 | Folios | `/folios` | See bill; post/settle path clear | +| 9 | Cashier | `/cashier` | Post/settle flow usable when folio has balance | +| 10 | Night audit | `/night-audit` | After run: success state + history; not “run again?” forever | +| 11 | Rate plans | `/rate-plans` | Calculate/edits not decorative | +| 12 | Reports | `/reports` | Primary reports load; errors visible | +| 13 | Groups | `/groups` | Group block list/detail operable | +| 14 | Channels | `/channels` | Connection path or honest “not configured” | +| 15 | Communications | `/communications` | Connected path or honest “not configured” | +| 16 | Reviews | `/reviews` | Sync/manual path clear — not a dead empty | +| 17 | Settings / Users | `/settings` | Property settings + users/roles operable | +| 18 | Booking admin | Settings → Booking Engine | Booking key generate/rotate when auth on | +| 19 | Import | `/import` | Import entry honest (works or clear empty) | + +Also smoke Walk-In party link + Guest Details — but **never instead of** this list. +Pair with [`vignettes/`](./vignettes/) for desk realism. + +## Outputs (suggested) + +Write under a local folder you keep private (e.g. `harden-runs/YYYY-MM-DD-surface-smoke/`): + +1. `AUDIT.json` — `{id, surface, route, classification, notes}` × each row +2. `SUMMARY.md` — counts of WORKS / BROKEN / EMPTY_SHELL / … +3. Screenshots only if useful; scrub guest PII + +## Gate + +**Invalid run:** skipped surfaces, or vignette-only run labeled as full product smoke. +**Valid run:** every row attempted. +**Go-live:** zero Critical on check-in, room status, folios, night-audit feedback, housekeeping. diff --git a/ops/harden/TENANT_ISOLATION.md b/ops/harden/TENANT_ISOLATION.md new file mode 100644 index 0000000..00674a1 --- /dev/null +++ b/ops/harden/TENANT_ISOLATION.md @@ -0,0 +1,49 @@ +# Tenant isolation gate (self-hosted HAIP) + +Run before enabling real hotel tenants. Uses two Keycloak users / properties. + +## Prerequisites + +- HAIP API with `AUTH_ENABLED=true` +- Two Keycloak users whose JWTs include: + - User A: `property_ids=[PROPERTY_A]`, roles include staff or `admin` + - User B: `property_ids=[PROPERTY_B]` +- Environment (see [`.env.harden.example`](./.env.harden.example)): + +| Variable | Meaning | +|----------|---------| +| `HAIP_API_BASE` | API base ending in `/api` (e.g. `http://localhost:3000/api`) | +| `TOKEN_A` / `TOKEN_B` | Bearer JWTs for users A and B | +| `PROPERTY_A` / `PROPERTY_B` | Property UUIDs | +| `RESERVATION_IN_B` | Optional — reservation id that belongs only to B | + +## Automated probe + +```bash +set -a && source .env.harden && set +a +pnpm harden:live +``` + +## Pass criteria + +1. `GET /v1/health` → **200** with `status: ok` (public) +2. `GET /v1/reservations?propertyId=PROPERTY_A` with **no** token → **401** +3. `GET /v1/reservations?propertyId=PROPERTY_B` with `TOKEN_A` → **403** (or **401** if claim missing) +4. `GET /v1/reservations?propertyId=PROPERTY_A` with `TOKEN_A` → **200** (A may read A) +5. If `RESERVATION_IN_B` is set: `GET /v1/reservations/RESERVATION_IN_B?propertyId=PROPERTY_B` with `TOKEN_A` → **403** or **404** +6. Token without usable `property_ids` / wrong issuer → **401** (configure a bad token as `TOKEN_BAD` optional) + +## Multi-property owner invariants (same account) + +An owner JWT may hold `property_ids=[A, B]`. Still required: + +1. Request with `propertyId=A` + entity id that only exists under B → **404** +2. `POST /reservations` with `propertyId=A` and a `guestId` only linked at B → **404** +3. SPA property switch clears cached detail data; detail routes key by `propertyId` + +These owner invariants are checklist items in v1 (exercise manually or with your own scripts). +The CLI covers the two-user cross-tenant deny path above. + +## After changes + +Re-run `pnpm harden:live` after any change to auth guards, property scoping, or JWT claim mapping. diff --git a/ops/harden/cli/harden.mjs b/ops/harden/cli/harden.mjs new file mode 100644 index 0000000..b8589f0 --- /dev/null +++ b/ops/harden/cli/harden.mjs @@ -0,0 +1,102 @@ +#!/usr/bin/env node +/** + * HAIP operator harden CLI + * + * node ops/harden/cli/harden.mjs local + * node ops/harden/cli/harden.mjs live + * node ops/harden/cli/harden.mjs all + * + * Env: see ops/harden/.env.harden.example + */ + +import { printReport, env } from './lib.mjs'; +import { runLocalFileProbes } from './probes/local.mjs'; +import { runHealthProbes } from './probes/health.mjs'; +import { runAuthOnProbes } from './probes/auth-on.mjs'; +import { runTenantIsolationProbes } from './probes/tenant-isolation.mjs'; + +function usage() { + console.log(`Usage: haip-harden + + local File/compose checklist (+ optional HTTP if HAIP_API_BASE is set) + live Health + auth-on + tenant-isolation against HAIP_API_BASE + all local then live + +Env: copy ops/harden/.env.harden.example → .env.harden and source it. +`); +} + +async function runLive() { + /** @type {import('./lib.mjs').ProbeResult[]} */ + const results = []; + results.push(...(await runHealthProbes())); + results.push(...(await runAuthOnProbes())); + results.push(...(await runTenantIsolationProbes())); + return results; +} + +async function runLocal() { + /** @type {import('./lib.mjs').ProbeResult[]} */ + const results = [...(await runLocalFileProbes())]; + + // If API base is configured, also hit health (and auth/tenant when tokens present) + if (env('HAIP_API_BASE')) { + console.log('HAIP_API_BASE set — running HTTP probes against local/target API…'); + results.push(...(await runHealthProbes())); + if (env('PROPERTY_A')) { + results.push(...(await runAuthOnProbes())); + } + if (env('TOKEN_A') && env('TOKEN_B') && env('PROPERTY_A') && env('PROPERTY_B')) { + results.push(...(await runTenantIsolationProbes())); + } else { + results.push({ + id: 'tenant-live-optional', + ok: true, + skip: true, + detail: 'TOKEN_A/B + PROPERTY_A/B not all set — skipped live tenant probes', + }); + } + } else { + results.push({ + id: 'http-optional', + ok: true, + skip: true, + detail: 'HAIP_API_BASE not set — file checks only; set it to probe a running API', + }); + } + + return results; +} + +async function main() { + const mode = (process.argv[2] ?? '').toLowerCase(); + if (!['local', 'live', 'all'].includes(mode)) { + usage(); + process.exit(2); + } + + console.log(`HAIP harden — mode=${mode}`); + + /** @type {import('./lib.mjs').ProbeResult[]} */ + let results = []; + if (mode === 'local' || mode === 'all') { + console.log('\n== local =='); + results = results.concat(await runLocal()); + } + if (mode === 'live' || mode === 'all') { + console.log('\n== live =='); + if (!env('HAIP_API_BASE')) { + console.error('HAIP_API_BASE is required for live mode'); + process.exit(2); + } + results = results.concat(await runLive()); + } + + const passed = printReport(results); + process.exit(passed ? 0 : 1); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/ops/harden/cli/lib.mjs b/ops/harden/cli/lib.mjs new file mode 100644 index 0000000..769fdf6 --- /dev/null +++ b/ops/harden/cli/lib.mjs @@ -0,0 +1,97 @@ +/** + * Shared helpers for HAIP operator harden probes. + * Plain Node fetch — no third-party auth SDKs. + */ + +export function env(name, fallback) { + const v = process.env[name]; + if (v === undefined || v === '') return fallback; + return v; +} + +export function requireEnv(name) { + const v = process.env[name]; + if (!v) { + throw new Error(`Missing required env: ${name}`); + } + return v; +} + +/** Normalize to base ending without trailing slash; expect .../api */ +export function apiBase() { + const raw = requireEnv('HAIP_API_BASE').replace(/\/+$/, ''); + return raw; +} + +export function timeoutMs() { + const n = Number(env('HARDEN_TIMEOUT_MS', '15000')); + return Number.isFinite(n) && n > 0 ? n : 15000; +} + +/** + * @param {string} path - path under API base, e.g. `/v1/health` + * @param {{ method?: string, token?: string | null, headers?: Record }} [opts] + */ +export async function request(path, opts = {}) { + const base = apiBase(); + const url = `${base}${path.startsWith('/') ? path : `/${path}`}`; + const headers = { Accept: 'application/json', ...(opts.headers ?? {}) }; + if (opts.token) { + headers.Authorization = `Bearer ${opts.token}`; + } + const res = await fetch(url, { + method: opts.method ?? 'GET', + headers, + signal: AbortSignal.timeout(timeoutMs()), + }); + let bodyText = ''; + try { + bodyText = await res.text(); + } catch { + bodyText = ''; + } + let json = null; + try { + json = bodyText ? JSON.parse(bodyText) : null; + } catch { + json = null; + } + return { url, status: res.status, ok: res.ok, json, bodyText }; +} + +/** + * @typedef {{ id: string, ok: boolean, detail: string, skip?: boolean }} ProbeResult + */ + +/** @param {ProbeResult[]} results */ +export function printReport(results) { + const width = Math.max(...results.map((r) => r.id.length), 8); + console.log(''); + console.log(`${'PROBE'.padEnd(width)} RESULT DETAIL`); + console.log(`${'-'.repeat(width)} ------ ------`); + for (const r of results) { + const label = r.skip ? 'SKIP' : r.ok ? 'PASS' : 'FAIL'; + console.log(`${r.id.padEnd(width)} ${label.padEnd(6)} ${r.detail}`); + } + const failed = results.filter((r) => !r.skip && !r.ok); + const skipped = results.filter((r) => r.skip); + console.log(''); + console.log( + `Summary: ${results.length - failed.length - skipped.length} pass, ${failed.length} fail, ${skipped.length} skip`, + ); + return failed.length === 0; +} + +/** @param {boolean} expected @param {number} status @param {number[]} codes */ +export function statusIn(status, codes) { + return codes.includes(status); +} + +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +export function repoRootFromCli() { + // ops/harden/cli → repo root is ../../.. + const cliDir = path.dirname(fileURLToPath(import.meta.url)); + return path.resolve(cliDir, '../../..'); +} diff --git a/ops/harden/cli/package.json b/ops/harden/cli/package.json new file mode 100644 index 0000000..641e16b --- /dev/null +++ b/ops/harden/cli/package.json @@ -0,0 +1,18 @@ +{ + "name": "@telivityhaip/harden-cli", + "version": "0.1.0", + "private": true, + "license": "Apache-2.0", + "type": "module", + "bin": { + "haip-harden": "./harden.mjs" + }, + "scripts": { + "harden": "node harden.mjs", + "harden:local": "node harden.mjs local", + "harden:live": "node harden.mjs live" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/ops/harden/cli/probes/auth-on.mjs b/ops/harden/cli/probes/auth-on.mjs new file mode 100644 index 0000000..da979dd --- /dev/null +++ b/ops/harden/cli/probes/auth-on.mjs @@ -0,0 +1,71 @@ +import { env, request, statusIn } from '../lib.mjs'; + +/** @returns {Promise} */ +export async function runAuthOnProbes() { + /** @type {import('../lib.mjs').ProbeResult[]} */ + const results = []; + const propertyA = env('PROPERTY_A'); + + if (!propertyA) { + results.push({ + id: 'auth-unauthenticated', + ok: false, + skip: true, + detail: 'PROPERTY_A not set — skip unauth probe', + }); + return results; + } + + try { + const res = await request( + `/v1/reservations?propertyId=${encodeURIComponent(propertyA)}`, + ); + const ok = statusIn(res.status, [401]); + results.push({ + id: 'auth-unauthenticated', + ok, + detail: ok + ? `no token → ${res.status}` + : `expected 401 without token, got ${res.status}`, + }); + } catch (err) { + results.push({ + id: 'auth-unauthenticated', + ok: false, + detail: `request failed: ${err instanceof Error ? err.message : String(err)}`, + }); + } + + const tokenBad = env('TOKEN_BAD'); + if (tokenBad) { + try { + const res = await request( + `/v1/reservations?propertyId=${encodeURIComponent(propertyA)}`, + { token: tokenBad }, + ); + const ok = statusIn(res.status, [401]); + results.push({ + id: 'auth-bad-token', + ok, + detail: ok + ? `TOKEN_BAD → ${res.status}` + : `expected 401 for TOKEN_BAD, got ${res.status}`, + }); + } catch (err) { + results.push({ + id: 'auth-bad-token', + ok: false, + detail: `request failed: ${err instanceof Error ? err.message : String(err)}`, + }); + } + } else { + results.push({ + id: 'auth-bad-token', + ok: true, + skip: true, + detail: 'TOKEN_BAD not set', + }); + } + + return results; +} diff --git a/ops/harden/cli/probes/health.mjs b/ops/harden/cli/probes/health.mjs new file mode 100644 index 0000000..2d01034 --- /dev/null +++ b/ops/harden/cli/probes/health.mjs @@ -0,0 +1,27 @@ +import { request, statusIn } from '../lib.mjs'; + +/** @returns {Promise} */ +export async function runHealthProbes() { + /** @type {import('../lib.mjs').ProbeResult[]} */ + const results = []; + try { + const res = await request('/v1/health'); + const ok = + statusIn(res.status, [200]) && + (res.json?.status === 'ok' || res.bodyText.includes('"ok"')); + results.push({ + id: 'health', + ok, + detail: ok + ? `${res.status} ${res.url}` + : `expected 200 status=ok, got ${res.status} ${res.bodyText.slice(0, 120)}`, + }); + } catch (err) { + results.push({ + id: 'health', + ok: false, + detail: `request failed: ${err instanceof Error ? err.message : String(err)}`, + }); + } + return results; +} diff --git a/ops/harden/cli/probes/local.mjs b/ops/harden/cli/probes/local.mjs new file mode 100644 index 0000000..73cc7c4 --- /dev/null +++ b/ops/harden/cli/probes/local.mjs @@ -0,0 +1,111 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { repoRootFromCli } from '../lib.mjs'; + +/** + * Local / pre-go-live file and compose checks. + * @returns {Promise + */ +export async function runLocalFileProbes() { + const root = repoRootFromCli(); + /** @type {import('../lib.mjs').ProbeResult[]} */ + const results = []; + + const requiredFiles = [ + 'docker-compose.yml', + 'docker-compose.prod.yml', + 'docker-compose.auth.yml', + '.env.production.example', + 'docs/deployment.md', + 'ops/harden/CHECKLIST.md', + 'ops/harden/TENANT_ISOLATION.md', + 'ops/harden/SURFACE_SMOKE.md', + ]; + + for (const rel of requiredFiles) { + const full = path.join(root, rel); + const ok = fs.existsSync(full); + results.push({ + id: `file:${rel}`, + ok, + detail: ok ? 'present' : `missing at ${full}`, + }); + } + + // .env.production — warn if missing (operators may use other secret injection) + const envProd = path.join(root, '.env.production'); + if (fs.existsSync(envProd)) { + const text = fs.readFileSync(envProd, 'utf8'); + const checks = [ + { id: 'env:AUTH_ENABLED', re: /^\s*AUTH_ENABLED\s*=\s*true\s*$/m }, + { + id: 'env:no-insecure', + re: null, + ok: !/^\s*HAIP_ALLOW_INSECURE\s*=\s*true\s*$/m.test(text), + detailFail: 'HAIP_ALLOW_INSECURE=true must not be set in production', + }, + { + id: 'env:DATABASE_URL', + re: /^\s*DATABASE_URL\s*=\s*.+/m, + }, + { + id: 'env:REDIS_URL', + re: /^\s*REDIS_URL\s*=\s*.+/m, + }, + ]; + for (const c of checks) { + if (c.re) { + const ok = c.re.test(text); + results.push({ + id: c.id, + ok, + detail: ok ? 'ok' : `check failed in .env.production`, + }); + } else { + results.push({ + id: c.id, + ok: c.ok, + detail: c.ok ? 'ok' : c.detailFail, + }); + } + } + } else { + results.push({ + id: 'env:.env.production', + ok: true, + skip: true, + detail: + 'no .env.production yet — copy from .env.production.example before go-live', + }); + } + + // Prod compose must force AUTH_ENABLED + const prodCompose = path.join(root, 'docker-compose.prod.yml'); + if (fs.existsSync(prodCompose)) { + const text = fs.readFileSync(prodCompose, 'utf8'); + const authOn = /AUTH_ENABLED:\s*['"]?true['"]?/.test(text); + results.push({ + id: 'compose:prod-auth', + ok: authOn, + detail: authOn + ? 'docker-compose.prod.yml sets AUTH_ENABLED=true' + : 'docker-compose.prod.yml should set AUTH_ENABLED=true', + }); + } + + // Vignette pack present + const vignetteDir = path.join(root, 'ops/harden/vignettes'); + let vignetteCount = 0; + if (fs.existsSync(vignetteDir)) { + vignetteCount = fs + .readdirSync(vignetteDir) + .filter((f) => f.startsWith('base-') && f.endsWith('.md')).length; + } + results.push({ + id: 'vignettes', + ok: vignetteCount >= 20, + detail: `${vignetteCount} vignette files in ops/harden/vignettes`, + }); + + return results; +} diff --git a/ops/harden/cli/probes/tenant-isolation.mjs b/ops/harden/cli/probes/tenant-isolation.mjs new file mode 100644 index 0000000..2fc923a --- /dev/null +++ b/ops/harden/cli/probes/tenant-isolation.mjs @@ -0,0 +1,139 @@ +import { env, requireEnv, request, statusIn } from '../lib.mjs'; + +/** @returns {Promise} */ +export async function runTenantIsolationProbes() { + /** @type {import('../lib.mjs').ProbeResult[]} */ + const results = []; + + let tokenA; + let tokenB; + let propertyA; + let propertyB; + try { + tokenA = requireEnv('TOKEN_A'); + tokenB = requireEnv('TOKEN_B'); + propertyA = requireEnv('PROPERTY_A'); + propertyB = requireEnv('PROPERTY_B'); + } catch (err) { + results.push({ + id: 'tenant-env', + ok: false, + detail: err instanceof Error ? err.message : String(err), + }); + return results; + } + + if (propertyA === propertyB) { + results.push({ + id: 'tenant-env', + ok: false, + detail: 'PROPERTY_A and PROPERTY_B must be different UUIDs', + }); + return results; + } + + results.push({ + id: 'tenant-env', + ok: true, + detail: 'TOKEN_A/B and PROPERTY_A/B present', + }); + + // A may read A + try { + const res = await request( + `/v1/reservations?propertyId=${encodeURIComponent(propertyA)}`, + { token: tokenA }, + ); + const ok = statusIn(res.status, [200]); + results.push({ + id: 'tenant-a-reads-a', + ok, + detail: ok + ? `TOKEN_A + PROPERTY_A → ${res.status}` + : `expected 200, got ${res.status}`, + }); + } catch (err) { + results.push({ + id: 'tenant-a-reads-a', + ok: false, + detail: `request failed: ${err instanceof Error ? err.message : String(err)}`, + }); + } + + // A must not read B + try { + const res = await request( + `/v1/reservations?propertyId=${encodeURIComponent(propertyB)}`, + { token: tokenA }, + ); + const ok = statusIn(res.status, [403, 401]); + results.push({ + id: 'tenant-a-denied-b', + ok, + detail: ok + ? `TOKEN_A + PROPERTY_B → ${res.status}` + : `expected 403 or 401, got ${res.status}`, + }); + } catch (err) { + results.push({ + id: 'tenant-a-denied-b', + ok: false, + detail: `request failed: ${err instanceof Error ? err.message : String(err)}`, + }); + } + + // B may read B (sanity) + try { + const res = await request( + `/v1/reservations?propertyId=${encodeURIComponent(propertyB)}`, + { token: tokenB }, + ); + const ok = statusIn(res.status, [200]); + results.push({ + id: 'tenant-b-reads-b', + ok, + detail: ok + ? `TOKEN_B + PROPERTY_B → ${res.status}` + : `expected 200, got ${res.status}`, + }); + } catch (err) { + results.push({ + id: 'tenant-b-reads-b', + ok: false, + detail: `request failed: ${err instanceof Error ? err.message : String(err)}`, + }); + } + + const reservationInB = env('RESERVATION_IN_B'); + if (reservationInB) { + try { + const res = await request( + `/v1/reservations/${encodeURIComponent(reservationInB)}?propertyId=${encodeURIComponent(propertyB)}`, + { token: tokenA }, + ); + const ok = statusIn(res.status, [403, 404, 401]); + results.push({ + id: 'tenant-a-denied-res-b', + ok, + detail: ok + ? `TOKEN_A + RESERVATION_IN_B → ${res.status}` + : `expected 403/404/401, got ${res.status}`, + }); + } catch (err) { + results.push({ + id: 'tenant-a-denied-res-b', + ok: false, + detail: `request failed: ${err instanceof Error ? err.message : String(err)}`, + }); + } + } else { + results.push({ + id: 'tenant-a-denied-res-b', + ok: true, + skip: true, + detail: 'RESERVATION_IN_B not set', + }); + } + + return results; +} diff --git a/ops/harden/vignettes/README.md b/ops/harden/vignettes/README.md new file mode 100644 index 0000000..d8ab2ce --- /dev/null +++ b/ops/harden/vignettes/README.md @@ -0,0 +1,30 @@ +# Desk ops vignettes + +Guest situation → staff role uses HAIP → delight or learn what blocked it. + +Run these **after** [`../SURFACE_SMOKE.md`](../SURFACE_SMOKE.md). They do not replace it. + +| File | Theme | +|------|--------| +| `base-01-multi-room-walk-in.md` | Two rooms, one party (BLOCKER weight) | +| `base-02-accompanying-same-room.md` | Couple walk-in, both names | +| `base-03-arrival-unassigned-reg-card.md` | Arrival, room unassigned | +| `base-04-inhouse-guest-details.md` | Folio / accompanying from stay | +| `base-05-room-move-dnm.md` | Quiet room move / DNM | +| `base-06-early-checkout-zero-balance.md` | Early checkout, $0 | +| `base-07-checkout-blocked-balance.md` | Due out with balance | +| `base-08-group-multi-res-checkin.md` | Group arrival | +| `base-09-vip-loyalty-recognition.md` | VIP recognition | +| `base-10-notes-handover.md` | Shift handover notes | +| `base-11-locale-pt-BR-walk-in.md` | pt-BR desk UI | +| `base-12-near-full-second-type.md` | Second type sold out mid-flow | +| `base-13-hk-stayover-family-waiting.md` | HK dirty, family waiting | +| `base-14-reservations-corporate-modify.md` | Corporate modify / direct bill | +| `base-15-night-audit-due-out-balance.md` | Night audit + due-out balance | +| `base-16-gm-peak-occupancy-flash.md` | GM peak occupancy | +| `base-17-privacy-adjoining-request.md` | Privacy / adjoining | +| `base-18-cashier-post-and-settle.md` | Cashier post + settle | +| `base-19-no-show-morning-release.md` | No-show release | +| `base-20-reservations-walk-in-handoff.md` | Reservations → desk handoff | + +Classify failures with the same language as surface smoke: delight / annoy / block. diff --git a/ops/harden/vignettes/base-01-multi-room-walk-in.md b/ops/harden/vignettes/base-01-multi-room-walk-in.md new file mode 100644 index 0000000..9602b92 --- /dev/null +++ b/ops/harden/vignettes/base-01-multi-room-walk-in.md @@ -0,0 +1,22 @@ +# base-01 — Exhausted family, two rooms, one pass + +## Guest story +Saturday peak. A family of five walks in after a delayed flight — kids melting down in the lobby. They need **two rooms**, same nights, preferably one bill / one party so they aren’t split across the desk twice. + +## Staff job +`front_desk` on `/front-desk`: create both rooms under **one party**, assign, send them upstairs in one conversation. + +## Surfaces +`/front-desk` + +## Delight if +Shared conf# / party visible; both rooms assigned; family leaves the desk once. + +## Annoy if +Party invisible in the list; extra hops to Reservations/Folios to finish. + +## Block if +Second room requires a second independent walk-in; no party link; double-assign a room. + +## Severity if fail +**BLOCKER** diff --git a/ops/harden/vignettes/base-02-accompanying-same-room.md b/ops/harden/vignettes/base-02-accompanying-same-room.md new file mode 100644 index 0000000..ab663ee --- /dev/null +++ b/ops/harden/vignettes/base-02-accompanying-same-room.md @@ -0,0 +1,22 @@ +# base-02 — Couple walk-in, one room, both names + +## Guest story +A couple walks in late with no booking. They want **one room** and both names on the stay so either can request a key or ask about the folio later. + +## Staff job +`front_desk` on `/front-desk`: one walk-in, one room, accompanying / second guest recorded without inventing a second reservation. + +## Surfaces +`/front-desk` + +## Delight if +Both names visible later from Guest Details / in-house without a scavenger hunt. + +## Annoy if +Only one name sticks; second guest buried or requires a note hack. + +## Block if +Product forces a second reservation for the accompanying guest. + +## Severity if fail +**BLOCKER** if accompanying impossible; **FRICTION** if obscure but in-flow. diff --git a/ops/harden/vignettes/base-03-arrival-unassigned-reg-card.md b/ops/harden/vignettes/base-03-arrival-unassigned-reg-card.md new file mode 100644 index 0000000..85c1205 --- /dev/null +++ b/ops/harden/vignettes/base-03-arrival-unassigned-reg-card.md @@ -0,0 +1,22 @@ +# base-03 — Arrival waiting, room still unassigned + +## Guest story +OTA guest arrives on time. Reservation exists but **no room assigned**. They’re standing at the desk with ID ready; next guest is already in line. + +## Staff job +`front_desk` on `/front-desk`: assign a clean room and complete check-in / reg in one Arrivals pass. + +## Surfaces +`/front-desk` + +## Delight if +Assigned + checked in without leaving Arrivals; guest gets a room number quickly. + +## Annoy if +Must open Reservations or Rooms to finish assign. + +## Block if +Cannot assign from desk flow; check-in succeeds with no room and no hard warning. + +## Severity if fail +**BLOCKER** if check-in cannot complete; **FRICTION** if assign is buried. diff --git a/ops/harden/vignettes/base-04-inhouse-guest-details.md b/ops/harden/vignettes/base-04-inhouse-guest-details.md new file mode 100644 index 0000000..35a9862 --- /dev/null +++ b/ops/harden/vignettes/base-04-inhouse-guest-details.md @@ -0,0 +1,22 @@ +# base-04 — In-house guest asks for folio / PIN / who’s on the room + +## Guest story +An in-house guest calls the desk: “What’s my folio balance, and can my spouse get a key?” Staff needs Guest Details, folio entry, and accompanying names **from the stay**. + +## Staff job +`front_desk` on `/front-desk` (folio link OK as secondary): open stay context, answer balance/PIN/accompanying without losing the guest on hold. + +## Surfaces +`/front-desk` (optional secondary `/folios`) + +## Delight if +Guest Details + folio path from In-House in a few clicks. + +## Annoy if +Must search Guests/Reservations by name with no stay link. + +## Block if +In-house stay has no usable guest/folio path. + +## Severity if fail +**FRICTION** slow path; **BLOCKER** if unreachable. diff --git a/ops/harden/vignettes/base-05-room-move-dnm.md b/ops/harden/vignettes/base-05-room-move-dnm.md new file mode 100644 index 0000000..dd57ea8 --- /dev/null +++ b/ops/harden/vignettes/base-05-room-move-dnm.md @@ -0,0 +1,22 @@ +# base-05 — Guest wants a quieter room (move / DNM) + +## Guest story +Stayover guest complains about noise and asks to move. Housekeeping or desk may have marked do-not-move; guest is at the desk now, not “later via email.” + +## Staff job +`front_desk` on `/front-desk`: move to another vacant room; if DNM, explicit override/ack path — not a silent ignore. + +## Surfaces +`/front-desk` + +## Delight if +Move completes on Front Desk; new room shows on the stay immediately. + +## Annoy if +Must bounce to Rooms/Reservations to finish the move. + +## Block if +No in-house room move at all. + +## Severity if fail +**BLOCKER** if move impossible; **FRICTION** if path is buried. diff --git a/ops/harden/vignettes/base-06-early-checkout-zero-balance.md b/ops/harden/vignettes/base-06-early-checkout-zero-balance.md new file mode 100644 index 0000000..7ee4a00 --- /dev/null +++ b/ops/harden/vignettes/base-06-early-checkout-zero-balance.md @@ -0,0 +1,22 @@ +# base-06 — Early flight, zero balance, just leave + +## Guest story +Guest needs to leave at 6am for a flight. Folio is **$0**. They want a clean checkout and to go — no lecture, no scavenger hunt. + +## Staff job +`front_desk` or `cashier` on `/front-desk` (or folio if required): checkout with zero balance; room released; guest gone. + +## Surfaces +`/front-desk` (optional `/folios`) + +## Delight if +One clear checkout; stay leaves in-house; guest out in under a minute of desk time. + +## Annoy if +Extra unrelated confirms when balance is already zero. + +## Block if +Zero-balance checkout fails with no actionable reason. + +## Severity if fail +**BLOCKER** diff --git a/ops/harden/vignettes/base-07-checkout-blocked-balance.md b/ops/harden/vignettes/base-07-checkout-blocked-balance.md new file mode 100644 index 0000000..4bfc340 --- /dev/null +++ b/ops/harden/vignettes/base-07-checkout-blocked-balance.md @@ -0,0 +1,22 @@ +# base-07 — Due out but still owes money + +## Guest story +Guest tries to leave with an open minibar / unpaid charge. They’ll get angry if the desk “checks them out anyway” or if nobody can show the balance and take payment. + +## Staff job +`front_desk` / `cashier`: attempt checkout; **block or force settlement**; show amount and next action. + +## Surfaces +`/front-desk`, `/folios` + +## Delight if +Clear balance + pay/settle path; no silent bypass. + +## Annoy if +Error is opaque (“failed”) with no folio jump. + +## Block if +Checkout succeeds while balance remains outstanding with no warning. + +## Severity if fail +**BLOCKER** on silent bypass; **FRICTION** if blocked but settle path is obscure. diff --git a/ops/harden/vignettes/base-08-group-multi-res-checkin.md b/ops/harden/vignettes/base-08-group-multi-res-checkin.md new file mode 100644 index 0000000..09333d3 --- /dev/null +++ b/ops/harden/vignettes/base-08-group-multi-res-checkin.md @@ -0,0 +1,22 @@ +# base-08 — Small group arrives together + +## Guest story +A wedding party / small group block arrives at once — several rooms, same dates, one organizer at the desk with a list. Lobby is backing up. + +## Staff job +`front_desk` (or reservations → desk): identify the set and check in **multiple rooms in one session** without treating each as a random solo. + +## Surfaces +`/front-desk` + +## Delight if +Batch or guided multi-select check-in; party/group still linked after. + +## Annoy if +Each room is a full separate check-in with no shared context. + +## Block if +Cannot recover the set; forced into unrelated solo check-ins only. + +## Severity if fail +**BLOCKER** if multi-res party cannot check in as a set; **FRICTION** if slow but possible. diff --git a/ops/harden/vignettes/base-09-vip-loyalty-recognition.md b/ops/harden/vignettes/base-09-vip-loyalty-recognition.md new file mode 100644 index 0000000..4d5992d --- /dev/null +++ b/ops/harden/vignettes/base-09-vip-loyalty-recognition.md @@ -0,0 +1,22 @@ +# base-09 — Loyalty guest expects to be recognized + +## Guest story +A repeat / VIP guest arrives. They expect the desk to see status without them “proving” who they are for three minutes. + +## Staff job +`front_desk` on `/front-desk` or arrivals: spot VIP/loyalty (if product supports it) and open the profile without losing the arrival. + +## Surfaces +`/front-desk`, `/guests` + +## Delight if +Status visible at arrivals / match; desk greets accordingly. + +## Annoy if +Status exists but never shown at desk surfaces. + +## Block if +Wrong guest’s VIP data shown (trust break). + +## Severity if fail +**FRICTION** missing/hard to see; **BLOCKER** on cross-guest bleed; **NOTE** if feature absent from shipped UI (`NEEDS_DOMAIN` OK). diff --git a/ops/harden/vignettes/base-10-notes-handover.md b/ops/harden/vignettes/base-10-notes-handover.md new file mode 100644 index 0000000..9d1c138 --- /dev/null +++ b/ops/harden/vignettes/base-10-notes-handover.md @@ -0,0 +1,22 @@ +# base-10 — Mid-shift handover note + +## Guest story +Day shift learns “extra towels + late arrival after 11.” Night shift must see it when the guest shows up — not in someone’s head or Discord. + +## Staff job +`front_desk`: leave a note on the reservation/guest; next agent finds it from Front Desk without archaeology. + +## Surfaces +`/front-desk` + +## Delight if +Note survives navigation and is obvious on reopen. + +## Annoy if +Notes exist but are hard to find. + +## Block if +Note appears saved then disappears. + +## Severity if fail +**BLOCKER** on data loss; **FRICTION** if hard to find. diff --git a/ops/harden/vignettes/base-11-locale-pt-BR-walk-in.md b/ops/harden/vignettes/base-11-locale-pt-BR-walk-in.md new file mode 100644 index 0000000..5a9d813 --- /dev/null +++ b/ops/harden/vignettes/base-11-locale-pt-BR-walk-in.md @@ -0,0 +1,22 @@ +# base-11 — Brazilian guest, desk in pt-BR + +## Guest story +Property serves Brazilian guests; desk runs UI in `pt-BR`. Guest asks for a 2-night walk-in — copy must not look broken or untrustworthy. + +## Staff job +`front_desk`: complete walk-in with locale `pt-BR` if switcher exists; nights pluralize cleanly; flow still completable. + +## Surfaces +`/front-desk` (locale control wherever shipped) + +## Delight if +Clear pt-BR nights copy; walk-in finishes. + +## Annoy if +Nested English plural junk like `(2 NIGHT(S))` in pt-BR strings. + +## Block if +Locale switch prevents completing the walk-in. + +## Severity if fail +**FRICTION** for bad copy; **BLOCKER** only if task blocked; **ENV_BLOCKED** if locale unavailable. diff --git a/ops/harden/vignettes/base-12-near-full-second-type.md b/ops/harden/vignettes/base-12-near-full-second-type.md new file mode 100644 index 0000000..584bd59 --- /dev/null +++ b/ops/harden/vignettes/base-12-near-full-second-type.md @@ -0,0 +1,22 @@ +# base-12 — Peak night, second room type sold out mid-flow + +## Guest story +Family needs two rooms at arrival peak. First type is fine; **second type shows unavailable** mid-flow. They’re already emotionally invested — don’t leave a half-booking mess. + +## Staff job +`front_desk` on `/front-desk`: recover — alternate type, reduce rooms, or abort cleanly; keep first selection coherent. + +## Surfaces +`/front-desk` + +## Delight if +Clear unavailable message + clean recovery without orphan stays. + +## Annoy if +Confusing error but recoverable. + +## Block if +Silent fail, corrupt partial booking, or forced unlinked second walk-in. + +## Severity if fail +**BLOCKER** on corrupt/duplicate-walk-in; **FRICTION** if recoverable but messy. diff --git a/ops/harden/vignettes/base-13-hk-stayover-family-waiting.md b/ops/harden/vignettes/base-13-hk-stayover-family-waiting.md new file mode 100644 index 0000000..fce27f6 --- /dev/null +++ b/ops/harden/vignettes/base-13-hk-stayover-family-waiting.md @@ -0,0 +1,22 @@ +# base-13 — Family waiting; stayover room still dirty + +## Guest story +Arriving family is in the lobby. Their assigned room is still a **stayover dirty**. Kids are restless; GM is glancing at the desk. Someone must free a clean room without making the family wait through a folio tour. + +## Staff job +`housekeeping_supervisor` on `/housekeeping` (+ `/rooms` if needed): see the blocked arrival, push clean/inspect or reassign path so Front Desk can put them up — **without** wandering into Folios/Billing. + +## Surfaces +`/housekeeping`, `/rooms` + +## Delight if +Clear dirty/stayover signal + action; family gets a room without ops chaos. + +## Annoy if +Must leave HK into unrelated modules to understand the block. + +## Block if +HK dashboard broken (e.g. 500) so supervisor cannot act. + +## Severity if fail +**BLOCKER** if HK cannot run the board; **FRICTION** if path is slow/confusing. diff --git a/ops/harden/vignettes/base-14-reservations-corporate-modify.md b/ops/harden/vignettes/base-14-reservations-corporate-modify.md new file mode 100644 index 0000000..b3debbc --- /dev/null +++ b/ops/harden/vignettes/base-14-reservations-corporate-modify.md @@ -0,0 +1,22 @@ +# base-14 — Corporate guest extends stay, direct bill + +## Guest story +A corporate traveler on **direct bill** calls/emails: extend two nights. Company will pay; they don’t want a panic “we lost your rate” conversation. + +## Staff job +`reservations` on `/reservations` (rate glance OK): find the stay, extend dates, keep corporate/direct-bill context without rebuilding the booking from scratch. + +## Surfaces +`/reservations` + +## Delight if +Modify dates in one coherent flow; confirmation still makes sense. + +## Annoy if +Must cancel + recreate; direct bill context dropped. + +## Block if +No modify path and no honest failure — silent corruption. + +## Severity if fail +**FRICTION** if workaround exists; **BLOCKER** if stay cannot be extended safely; `NEEDS_DOMAIN` if product has no modify at all. diff --git a/ops/harden/vignettes/base-15-night-audit-due-out-balance.md b/ops/harden/vignettes/base-15-night-audit-due-out-balance.md new file mode 100644 index 0000000..6c5d70f --- /dev/null +++ b/ops/harden/vignettes/base-15-night-audit-due-out-balance.md @@ -0,0 +1,22 @@ +# base-15 — Night audit blocked by due-out with balance + +## Guest story +It’s roll time. One due-out still has an open balance. Night auditor must not blindly close the day and invent a ghost checkout — and must not spend 40 minutes hunting the folio. + +## Staff job +`night_auditor` on `/night-audit` + `/folios` / Front Desk departures: see the due-out with balance, resolve or consciously defer before audit. + +## Surfaces +`/night-audit`, `/folios`, `/front-desk` + +## Delight if +Audit surface flags the problem; clear next action. + +## Annoy if +Audit runs “fine” while due-out balance is invisible. + +## Block if +Occupancy/audit tools 500 so NA cannot close the day. + +## Severity if fail +**BLOCKER** if day cannot close safely; **FRICTION** if visible but painful. diff --git a/ops/harden/vignettes/base-16-gm-peak-occupancy-flash.md b/ops/harden/vignettes/base-16-gm-peak-occupancy-flash.md new file mode 100644 index 0000000..a8b7be2 --- /dev/null +++ b/ops/harden/vignettes/base-16-gm-peak-occupancy-flash.md @@ -0,0 +1,22 @@ +# base-16 — GM needs a peak occupancy flash + +## Guest story +Saturday peak. Front desk is slammed. GM walks in: “Are we full? Any walk-in left?” They need a trustworthy occupancy flash — not a broken report and not a 20-click export. + +## Staff job +`gm` on `/` and `/reports`: read occupancy / today’s flash quickly while FD works. + +## Surfaces +`/`, `/reports` + +## Delight if +Dashboard or occupancy report answers in one glance. + +## Annoy if +Numbers disagree across screens with no explanation. + +## Block if +Occupancy report 500 / unusable at peak. + +## Severity if fail +**BLOCKER** if GM cannot get occupancy; **FRICTION** if slow/inconsistent. diff --git a/ops/harden/vignettes/base-17-privacy-adjoining-request.md b/ops/harden/vignettes/base-17-privacy-adjoining-request.md new file mode 100644 index 0000000..911fe6c --- /dev/null +++ b/ops/harden/vignettes/base-17-privacy-adjoining-request.md @@ -0,0 +1,22 @@ +# base-17 — Two guests, privacy / adjoining ask + +## Guest story +Two adults check in with separate names. They ask for **adjoining or privacy-sensitive** placement (e.g. not announcing names loudly; prefer rooms near each other). Desk must service the ask without inventing a loyalty program. + +## Staff job +`front_desk` on `/front-desk`: capture the request (notes / room choice), assign appropriately, keep both guests serviceable. + +## Surfaces +`/front-desk` + +## Delight if +Request noted and rooms chosen in one pass; no lobby theater. + +## Annoy if +No place for the request; staff uses sticky notes IRL. + +## Block if +Cannot assign two related stays without unlinked chaos (ties to multi-room party). + +## Severity if fail +**FRICTION** if notes missing but assign works; **BLOCKER** if party/assign breaks; `NEEDS_DOMAIN` only if asking for a product concept that truly isn’t shipped. diff --git a/ops/harden/vignettes/base-18-cashier-post-and-settle.md b/ops/harden/vignettes/base-18-cashier-post-and-settle.md new file mode 100644 index 0000000..59dc9f8 --- /dev/null +++ b/ops/harden/vignettes/base-18-cashier-post-and-settle.md @@ -0,0 +1,22 @@ +# base-18 — Restaurant charge then settle at desk + +## Guest story +In-house guest signed a dinner charge to the room. At checkout they want to **see the charge and pay**. Cashier/desk must post/settle without a treasure hunt. + +## Staff job +`cashier` / `front_desk` on `/folios` (+ Front Desk): find folio, confirm charge, take payment / settle, then checkout path. + +## Surfaces +`/folios`, `/front-desk` + +## Delight if +Charge visible; settle → checkout coherent. + +## Annoy if +Charge exists in ops lore but not on the folio UI. + +## Block if +Cannot post/settle; checkout silently ignores balance (ties to base-07). + +## Severity if fail +**BLOCKER** on financial integrity; **FRICTION** if path is slow. diff --git a/ops/harden/vignettes/base-19-no-show-morning-release.md b/ops/harden/vignettes/base-19-no-show-morning-release.md new file mode 100644 index 0000000..2f4e0ab --- /dev/null +++ b/ops/harden/vignettes/base-19-no-show-morning-release.md @@ -0,0 +1,22 @@ +# base-19 — Morning no-show, free the room + +## Guest story +It’s morning. A guaranteed/no-show reservation didn’t arrive. House needs the room for a walk-in; desk/NA must release inventory without ghosting revenue policy blindly. + +## Staff job +`front_desk` or `night_auditor`: mark/handle no-show and free the room for sale; don’t leave a phantom arrival blocking Walk-In. + +## Surfaces +`/front-desk`, `/reservations` + +## Delight if +Clear no-show / release action; room available for next walk-in. + +## Annoy if +Status confusing; room stuck “arriving” all day. + +## Block if +Cannot free inventory; walk-ins blocked by ghosts. + +## Severity if fail +**FRICTION** if awkward; **BLOCKER** if inventory stuck. diff --git a/ops/harden/vignettes/base-20-reservations-walk-in-handoff.md b/ops/harden/vignettes/base-20-reservations-walk-in-handoff.md new file mode 100644 index 0000000..f31e744 --- /dev/null +++ b/ops/harden/vignettes/base-20-reservations-walk-in-handoff.md @@ -0,0 +1,22 @@ +# base-20 — Reservations books walk-in that FD must finish + +## Guest story +Phone rings at reservations during peak: walk-in-ish same-day stay. Reservations starts the booking; guest will be at the desk in 20 minutes for keys. + +## Staff job +`reservations` creates/holds the stay on `/reservations`; `front_desk` completes assign/check-in on `/front-desk` without re-entering the whole guest as a new walk-in. + +## Surfaces +`/reservations`, `/front-desk` + +## Delight if +Handoff is one stay; FD finishes assign/check-in fast. + +## Annoy if +FD cannot find the booking; duplicates created. + +## Block if +Forced second independent create at the desk. + +## Severity if fail +**BLOCKER** if duplicate create required; **FRICTION** if findable but slow. diff --git a/package.json b/package.json index 4c8af16..472f444 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,9 @@ "db:studio": "pnpm --filter @telivityhaip/database run db:studio", "docker:up": "docker compose up -d", "docker:down": "docker compose down", + "harden:local": "node ops/harden/cli/harden.mjs local", + "harden:live": "node ops/harden/cli/harden.mjs live", + "harden": "node ops/harden/cli/harden.mjs all", "clean": "pnpm -r run clean && rm -rf node_modules", "integrations:demo": "bash integrations/demos/run.sh" },