Hamsun. Master Portal · Architecture ↗ All prototypes

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.

Gueston phone
WhatsAppdeep link with ?c=CODE
Master Portalhamsun.pk/?c=…
Breakfast/portal/breakfast
Request/portal/request/:type
Cleaning prefs/portal/cleaning
My stay/portal/stay
Edge fnsportal-submit-request
submit-menu-order
Databaseportal.requests
service_menus.orders
bookings prefs
NotificationsWhatsApp ack
Slack to team
PMS Reception Inboxstaff acts on requests · workflow engine renders dynamic buttons

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.

pre_checkin arrival_day in_house departure_day post_checkout

State transitions

  • pre_checkin → from booking creation until check-in date approaches (1 day before)
  • arrival_day → today is check-in date AND checkin_status is still PENDING
  • in_housecheckin_status = CHECKED_IN
  • departure_day → today is check-out date AND not yet checked out
  • post_checkoutcheckin_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)

Hello, Marutani
Room G06 · Hamsun FSL · checkout Sun 27 Apr
🥐
Pre-order tomorrow's breakfast
Closes at 10pm tonight
🛎
Make a request
Towels · F&B · transport · maintenance · more
🧹
Cleaning preferences
Daily cleaning: ON · 10am
🛒
Mini-shop
Snacks, drinks, toiletries — order to your room
📋
My charges
Folio total: PKR 87,500 · 8 items
Leave feedback
Available after checkout

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 backend
URL /portal/breakfast — opens to time slot picker Frontend Lives in hamsun-portal repo · prototype at breakfast-prototype Backend tables service_menus.menus · time_slots · menu_items · choice_groups · sides · drinks · sessions · orders · order_items Edge functions get-menu-context · submit-menu-order · lock-menu-orders (cron) On submit Charges post via post-charge edge fn with category='room_service' · KOT to kitchen Slack · WhatsApp ack to guest Lock window Per menu config lock_minutes_before (default 40 min before slot) Depends on Menu admin backend wiring (Phase 5 of menu-admin)
🍳

2. Request · F&B (ad-hoc kitchen orders)

Live
URL /portal/request/kitchen_fnb Backend portal.requests with type_slug='kitchen_fnb' · catalog from portal.catalog_items Edge function portal-submit-request Workflow new → confirmed → preparing → ready → delivered (charge posted on delivery) Notifications Slack to kitchen channel on submit · WhatsApp ack to guest
🛒

3. Request · Mini-shop / Sundries

Live
URL /portal/request/mini_shop Frontend Live at hamsun-portal · prototype at portal-mockup Backend portal.requests with type_slug='mini_shop' · stock decrements via portal_adjust_catalog_stock RPC Catalog 7 categories (drinks, munchies, otc_meds, phone, toiletries, packing) — comp items free, others priced Charge category grocery
🧺

4. Request · Housekeeping items

Live
URL /portal/request/housekeeping What it does Towels, linen, toiletries refill, slippers, kettle, hairdryer, robe, baby cot, ironing board, extra pillow Backend portal.requests with type_slug='housekeeping' · subtypes: towels, linen, amenities, laundry_pickup Workflow new → acknowledged → assigned → in_progress → done (free, no charge) Notifications Slack to housekeeping team · WhatsApp ack + done-confirmation to guest
🧹

5. Request · Cleaning + Cleaning preferences

Spec'd
URLs /portal/request/cleaning (one-off) · /portal/cleaning (preferences toggle) Spec housekeeping-module — full flow doc with 8 open Qs Backend (new) 5 booking columns + new cleaning request_type + housekeeping.daily_cron_log + 2 source enum values Auto sources Daily cron (opted-in bookings) · post-checkout DB trigger Manual sources Guest taps "Request cleaning" · reception PMS manual entry Auto-daily opt-in Portal toggle · WhatsApp reply (DAILY/STOP/SKIP) · PMS booking edit
🔧

6. Request · Maintenance

Live
URL /portal/request/maintenance Subtypes ac_heating, plumbing, electrical, wifi_tv, furniture, appliance, other Severity urgent / normal / low (guest-selectable) Workflow new → triaged → assigned → in_progress → done (or waiting_parts / cannot_resolve) Notifications Slack to maintenance team · technician notified on assignment · guest gets resolution WhatsApp
🚗

7. Request · Transport

Live
URL /portal/request/airport_transport Subtypes airport_pickup, airport_drop, city_drop, city_tour Workflow new → scheduled → driver_assigned → in_transit → completed (charge on completion) Required field Schedule action requires scheduled_for (date+time) Charge category airport_transfer
🛎

8. Request · Concierge / general

Live
URL /portal/request/concierge Subtypes wake_up_call, lost_and_found, info_request, feedback, complaint, recommendation Note Wake-up call, complaint, lost-and-found may split into own request_types in future (per workflow review)
📅

9. Request · Booking action (late checkout, extension)

Partial
URL /portal/request/booking_action Subtypes late_checkout, early_checkin, extend_stay, shorten_stay, room_change, special_occasion Approval flow new → pending_approval → approved/rejected → completed · approve action requires manager role Open issues Approve action lacks amount-capture field · role enforcement not yet wired in PMS · subtypes mix flows that should be split Charge Posts on completion · amount calculated per subtype (e.g., late checkout = 50% × nightly rate)

Data 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

TableOwnsRead byWritten 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

FunctionCalled byEffect
portal-submit-requestAll "Request" sub-flowsInserts portal.requests row · fires request_received WhatsApp + Slack
submit-menu-orderBreakfast portal (planned)Validates choices · computes folio · posts charges · KOT to kitchen Slack
portal_resolve_code (RPC)Master portal landingResolves portal code → booking + state

Outbound · backend → 3rd party

FunctionTriggers whenSends to
send-whatsappRequest received · status changes (notify_guest_on_action) · cleaning opt-in confirmationsWhatsApp API → guest WhatsApp number
send-slackRequest received · approvals needed · escalations · KOTSlack channel C099X389C7Q (per-team channels possible)
send-emailBooking confirmations · invoices · receiptsResend.com → guest email

Triggers · database event → side effect

TriggerFires onDoes
post-checkout cleaning (planned)UPDATE bookings.checkin_statusCHECKED_OUTInserts cleaning request with subtype='post_checkout'
audit_log triggerModifications on tracked tables (~17)Inserts audit_log row with old/new diff
request status historyUPDATE portal.requests.statusInserts portal.request_status_history row

Cron · scheduled work

JobScheduleDoes
daily-cleaning-cron (planned)09:00 PKT dailyGenerates daily_clean tasks for opted-in CHECKED_IN bookings (with skip rules)
DND reset23:59 PKT dailyResets cleaning_dnd_today = false on all bookings
lock-menu-ordersEvery 5 minLocks menu sessions whose slots are within lock_minutes_before
send-menu-formPer menu configWhatsApp pre-order link to guests
send-menu-reminderPer menu configMorning-of reminder for unfilled orders
daily-menu-summaryPer 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).

EventWhatsAppSlackEmail
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 confirmation
  • checkin_welcome_cleaning_opt_in — at check-in, includes daily-cleaning prompt
  • request_received — generic ack
  • request_done — generic completion
  • request_cancelled — generic cancel/explanation
  • cleaning_done — specific to cleaning module
  • cleaning_skip_confirmed — DND ack
  • cleaning_pref_changed — toggle ack
  • folio_charge_posted — receipt
  • checkout_summary — final folio
  • feedback_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.

Master portal landing — already lives in hamsun-portal
No new dependencies. Stay state already determined via portal_resolve_code.
done
Portal.requests + workflow engine
7 request types seeded · WorkflowActionBar.tsx renders dynamic buttons.
done
1
Workflow vocabulary cleanup (Slice 1)
Operator edits in workflows-admin → SQL migration applied. Blocks: nothing strictly, but better-labeled buttons benefit every flow.
~2 hr
2
Cleaning sub-flow (housekeeping module)
DB cols on bookings + cleaning request_type + post-checkout trigger + cron + WhatsApp parser + portal preferences page. Depends on: Q1–Q8 answered.
~32 hr
3
Reception inbox v2 (React rebuild)
Replaces RequestsPage.tsx with command-center layout, undo, peek cards. Depends on: housekeeping live (so peek cards have real data).
~34 hr
4
Workflows admin (React in PMS)
Replaces standalone HTML editor with real Settings page. Depends on: vocabulary stable from Slice 1.
~12 hr
5
Menu admin backend (breakfast first)
All of menu-admin's Phase 1–9 (~24 hr). Depends on: nothing in this list — independent track.
~24 hr
6
Breakfast portal wired to menu backend
Depends on: step 5.
~8 hr
7
WhatsApp template approvals + adapter layer polish
Get all required templates approved by the WhatsApp provider · clean up adapter layer in manage-whatsapp-group for future provider swaps. Depends on: nothing technically; gates which notifications can fire.
~16 hr (+ template approval lead time)
8
Concierge subtype split-out (wake-up call, complaint, lost-and-found)
Each becomes own request_type if needed. Depends on: step 4 (admin UI exists to add new types easily).
~8 hr

Critical invariants · what never to break

Things that must remain true across all module builds. Violating one causes silent data corruption or production breakage.

🛑
Every request goes through portal.requests
No new tables for "cleaning tasks" or "transport requests". Every guest-initiated request — and every staff-initiated task — is a row in portal.requests. The category is type_slug, the lifecycle is workflow.status. This guarantees consistent reporting, audit, and reception inbox surfacing.
🛑
Charges always go through post-charge edge fn
No direct INSERT into 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.
🛑
No secrets in the prototypes repo
This repo is public. Don't paste API keys, booking IDs, guest names, phone numbers, or production data into HTML files or DESIGN.md docs. Mockup data uses fake names (Marutani, Mohsin, etc.) — keep it that way.
Portal code must resolve to exactly one booking
If 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.
Workflow status keys are immutable once seeded
Workflows admin lets you rename labels but the underlying status 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.
Stay state derives from booking, never stored on portal.requests
The state (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.
Notifications must be idempotent
Edge functions like 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.
Cleaning module reuses portal.requests — no parallel schema
When building housekeeping module, do NOT create a 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.
Every status change must log to history
The 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.
Per-property differences live in app_settings or property_id columns, not in code
Hamsun has 3 properties (CLF, FSL, EXT). Don't hardcode "9am cron for FSL only" in edge functions. Store the schedule in app_settings or per-property config tables. Otherwise expanding to a 4th property requires code changes.