Master Portal Architecture
The architecture spine that ties every guest-facing module together — breakfast pre-orders, cleaning preferences, mini-shop, transport, maintenance, all the request types. Each module hangs off this master portal; this doc defines the wiring so we can build modules in parallel without breaking each other.
Read this before opening any other module spec. Once locked, each module's DESIGN.md defers to this for the cross-cutting concerns (auth, state, data flow, notifications).
The big picture
One guest enters via a WhatsApp link. They land on the master portal. The portal routes them to one of 8 sub-flows. Each sub-flow writes to a specific backend table, fires its own notifications, and follows a typed state machine.
submit-menu-order
service_menus.orders
bookings prefs
Slack to team
Entry · portal code → stay state
Every guest-facing flow starts with a 6-character code embedded in a WhatsApp deep link. The code maps to exactly one booking, which determines what features are available.
URL pattern
WhatsApp message: "Welcome to Hamsun! Open your stay portal here: https://hamsun.pk/?c=A4B7K2"
Code resolution
Frontend calls portal_resolve_code(p_code text) RPC, which returns:
- booking — id, beds24_booking_id, dates, status, checkin_status, room, property
- guest — id, name, phone, language preference
- property — id, code (CLF/FSL/EXT), name, time zone, default settings
- state — derived stay state (see §3 below)
- preferences — auto_daily_cleaning, cleaning_pref_time, dietary notes, etc.
Code lifecycle
- Generated when booking is created (CONFIRMED status)
- Embedded in welcome WhatsApp at confirmation + check-in welcome
- Valid for the entire booking window + 7 days post-checkout (for receipts/feedback)
- One code per booking (never reused)
What the portal stores in browser context
Once the code resolves, the portal keeps booking ID, guest ID, property ID, and stay state in PortalContext. Every sub-flow reads from this context — no need to re-resolve.
Stay state machine
A booking moves through 5 states. The state determines which sub-flows are available, what data is editable, and what notifications fire.
State transitions
- pre_checkin → from booking creation until check-in date approaches (1 day before)
- arrival_day → today is check-in date AND
checkin_statusis stillPENDING - in_house →
checkin_status = CHECKED_IN - departure_day → today is check-out date AND not yet checked out
- post_checkout →
checkin_status = CHECKED_OUT(window: 7 days)
Features enabled per state
| Sub-flow | pre_checkin | arrival_day | in_house | departure_day | post_checkout |
|---|---|---|---|---|---|
| Welcome / stay info | ✓ | ✓ | ✓ | ✓ | ✓ |
| Breakfast pre-order | — | ✓ | ✓ | — | — |
| Request: F&B | — | — | ✓ | ✓ | — |
| Request: Mini-shop | — | ✓ | ✓ | ✓ | — |
| Request: Housekeeping items | — | — | ✓ | ✓ | — |
| Cleaning preferences | — | ✓ | ✓ | — | — |
| Request: Cleaning | — | — | ✓ | — | — |
| Request: Maintenance | — | — | ✓ | ✓ | — |
| Request: Transport | ✓ | ✓ | ✓ | ✓ | — |
| Request: Concierge | ✓ | ✓ | ✓ | ✓ | — |
| Request: Booking action | — | ✓ | ✓ | ✓ | — |
| View charges / folio | ✓ | ✓ | ✓ | ✓ | ✓ |
| Leave feedback | — | — | — | ✓ | ✓ |
Master portal landing · what guest sees
The first screen after the code resolves. Cards visible depend on stay state. Each card is a route to a sub-flow.
Visual mockup (in_house state)
Sub-flows · 8 portals under one roof
Each sub-flow has its own URL, its own backend table(s), and its own state machine. Wiring is consistent across all of them — same auth via PortalContext, same notification topology, same audit trail.
1. Breakfast pre-order
Spec'd · awaits backend2. Request · F&B (ad-hoc kitchen orders)
Live3. Request · Mini-shop / Sundries
Live4. Request · Housekeeping items
Live5. Request · Cleaning + Cleaning preferences
Spec'd6. Request · Maintenance
Live7. Request · Transport
Live8. Request · Concierge / general
Live9. Request · Booking action (late checkout, extension)
PartialData model · who owns what
Each table has a single source-of-truth role. Cross-table joins are explicit. No data lives in two places.
Tables
| Table | Owns | Read by | Written by |
|---|---|---|---|
public.bookings |
Stay record · guest preferences (incl. auto_daily_cleaning, cleaning_pref_time) |
All sub-flows · PMS · Beds24 sync | PMS · Beds24 webhook · WhatsApp parser (preferences only) |
portal.request_types |
Workflow JSONB per category (statuses, actions, transitions, flags) | Portal · PMS reception inbox · workflow engine | Workflows admin (or SQL migration) |
portal.requests |
Every guest request across all categories (one row per request) | Portal status checks · PMS reception inbox | portal-submit-request · portal-update-request-status · DB trigger (post-checkout) · cron (daily-cleaning) |
portal.catalog_items |
Item library (mini-shop, F&B, etc.) — pricing, stock, lifecycle states | Portal request flows | Catalog admin in PMS |
service_menus.menus |
Menu config (breakfast/lunch/dinner) — pricing mode, time slots | Breakfast portal · breakfast-prototype | Menu admin |
service_menus.orders |
Submitted menu orders (e.g. breakfast pre-orders) | Kitchen Slack · PMS | submit-menu-order edge fn |
charges |
Folio line items — every charge that hits a guest's bill | PMS · invoicing | post-charge edge fn (called by sub-flows on terminal action) |
portal.request_status_history |
Audit trail of every status change | PMS detail drawer · audit log | portal-update-request-status (auto-inserted on status change) |
notification_log |
Record of every WhatsApp/email/Slack sent | PMS · debugging | All notification edge fns |
audit_log |
Trigger-based change log on critical tables | Integrity Center | DB triggers |
housekeeping.daily_cron_log |
Per-booking-per-day record of cleaning cron decision | Housekeeping team · debugging | daily-cleaning-cron edge fn (planned) |
Edge function topology
Four categories of edge functions. Inbound (portal calls), outbound (notify 3rd party), triggers (DB → side effect), cron (scheduled work).
Inbound · guest portal → backend
| Function | Called by | Effect |
|---|---|---|
portal-submit-request | All "Request" sub-flows | Inserts portal.requests row · fires request_received WhatsApp + Slack |
submit-menu-order | Breakfast portal (planned) | Validates choices · computes folio · posts charges · KOT to kitchen Slack |
portal_resolve_code (RPC) | Master portal landing | Resolves portal code → booking + state |
Outbound · backend → 3rd party
| Function | Triggers when | Sends to |
|---|---|---|
send-whatsapp | Request received · status changes (notify_guest_on_action) · cleaning opt-in confirmations | WhatsApp API → guest WhatsApp number |
send-slack | Request received · approvals needed · escalations · KOT | Slack channel C099X389C7Q (per-team channels possible) |
send-email | Booking confirmations · invoices · receipts | Resend.com → guest email |
Triggers · database event → side effect
| Trigger | Fires on | Does |
|---|---|---|
| post-checkout cleaning (planned) | UPDATE bookings.checkin_status → CHECKED_OUT | Inserts cleaning request with subtype='post_checkout' |
| audit_log trigger | Modifications on tracked tables (~17) | Inserts audit_log row with old/new diff |
| request status history | UPDATE portal.requests.status | Inserts portal.request_status_history row |
Cron · scheduled work
| Job | Schedule | Does |
|---|---|---|
daily-cleaning-cron (planned) | 09:00 PKT daily | Generates daily_clean tasks for opted-in CHECKED_IN bookings (with skip rules) |
| DND reset | 23:59 PKT daily | Resets cleaning_dnd_today = false on all bookings |
lock-menu-orders | Every 5 min | Locks menu sessions whose slots are within lock_minutes_before |
send-menu-form | Per menu config | WhatsApp pre-order link to guests |
send-menu-reminder | Per menu config | Morning-of reminder for unfilled orders |
daily-menu-summary | Per menu config (e.g. 06:30) | Slack KOT summary by slot |
Notification topology
Who hears about what, when. Three channels: WhatsApp (guest), Slack (staff), email (formal records).
| Event | Slack | ||
|---|---|---|---|
| Booking confirmed | Welcome + portal link | — | Confirmation receipt |
| Check-in | Welcome at property + auto-cleaning opt-in offer | Reception channel ack | — |
| Request received (any type) | request_received ack to guest |
Per-team channel — e.g. kitchen Slack on F&B | — |
| Request status change | Only if action has notify_guest_on_action=true |
Optional escalation messages (e.g. "stuck >SLA") | — |
| Request done (terminal success) | request_done auto-fires (set by SUCCESS_TERMINAL_STATUSES) |
— | — |
| Charge posted to folio | Receipt with running balance | — | — |
| Manager approval needed | — | Approval channel with action buttons (existing approve-payment-slack pattern) |
— |
| Cleaning opt-in toggle | Confirmation reply (DAILY / STOP / SKIP) | — | — |
| Checkout | Folio summary + feedback link (post 24h) | — | Final invoice if requested |
WhatsApp template inventory
Every notification message uses a pre-approved WhatsApp template. Templates needed:
welcome_with_portal— at booking confirmationcheckin_welcome_cleaning_opt_in— at check-in, includes daily-cleaning promptrequest_received— generic ackrequest_done— generic completionrequest_cancelled— generic cancel/explanationcleaning_done— specific to cleaning modulecleaning_skip_confirmed— DND ackcleaning_pref_changed— toggle ackfolio_charge_posted— receiptcheckout_summary— final foliofeedback_request— post 24h
Build sequence · what depends on what
Order of operations to build modules without breaking each other. Dependencies are real — building out of order causes rework.
hamsun-portalportal_resolve_code.WorkflowActionBar.tsx renders dynamic buttons.cleaning request_type + post-checkout trigger + cron + WhatsApp parser + portal preferences page. Depends on: Q1–Q8 answered.RequestsPage.tsx with command-center layout, undo, peek cards. Depends on: housekeeping live (so peek cards have real data).manage-whatsapp-group for future provider swaps. Depends on: nothing technically; gates which notifications can fire.Critical invariants · what never to break
Things that must remain true across all module builds. Violating one causes silent data corruption or production breakage.
portal.requests. The category is type_slug, the lifecycle is workflow.status. This guarantees consistent reporting, audit, and reception inbox surfacing.charges from any portal flow. The action that has posts_charge: true calls post-charge which handles tax mode, currency, audit log, Beds24 backup posting. Bypassing this breaks the folio.portal_resolve_code ever returns multiple bookings or null when a code is valid, every downstream flow breaks. Add tests; don't change the resolution function casually.key is referenced by portal.requests.status, portal.request_status_history.from_status/to_status, and notification template names. Renaming a key requires data migration on every reference.pre_checkin / in_house / etc.) is computed from bookings.check_in + checkin_status + check_out. Don't denormalise it onto requests; if booking dates change, stale state on requests would lie.sendRequestNotification(requestId, eventKey) dedupe internally. Calling them twice for the same event must not send two messages. Notification logic must be in the edge fn, not the React caller.housekeeping.cleaning_tasks table. Use portal.requests with type_slug='cleaning'. The bookkeeping log (housekeeping.daily_cron_log) is fine — it's a side log, not a parallel store.portal_update_request_status RPC inserts into portal.request_status_history automatically. If a future code path mutates requests.status directly via a SQL UPDATE, the audit trail breaks. Always go through the RPC.app_settings or per-property config tables. Otherwise expanding to a 4th property requires code changes.