Appearance
Hotel PBX
The Hotel PBX app (slug hotel_pbx) is a hospitality front desk over a FusionPBX tenant: a live room board with guest, occupancy and housekeeping status, where checking a guest in and out actually reconfigures the room phone.
It is a port of FSPBX's Laravel hotel module (App\Services\HotelRoomService and the HotelRoom / HotelRoomStatus / HotelHousekeepingDefinition models), which was built but never routed — no route file referenced its controllers, so the only way in was a pair of React sections under Account Settings.
What you can do
- Map rooms to extensions — a room is a room name plus the phone in it. One extension can only be one room, and the app refuses a second mapping.
- Check a guest in — the room phone takes their name, voicemail is switched back on, and the previous guest's messages, greetings and wake-up calls are cleared.
- Check them out — the phone is renamed "Vacant" and voicemail is switched off. Housekeeping status is deliberately left alone: a room a guest just left is dirty, and clearing it would say the opposite.
- Move a guest between rooms — their details and dates follow them; the destination's voicemail box is cleared for them.
- Set a wake-up call — the room rings at the time you set and the guest can snooze from the handset; optionally repeating daily. Setting the same time twice re-arms the existing call rather than stacking two.
- Let housekeeping update a room from the room phone —
*26plus a one-or-two-digit code, and the board follows. - Define what those codes mean — per hotel, because that is how FreeSWITCH looks them up.
Where the data lives, and why that is different here
Every other app the portal has added recently keeps its own tables in the lubb schema. This one must not.
resources/lua/hotel_room_status_update.lua runs from an enabled dialplan entry (*26[d{1,2}], hotel-room-status-update) whenever somebody dials a status from a room phone. It resolves the extension in v_extensions, the room in hotel_rooms, the code in hotel_housekeeping_definitions, and upserts hotel_room_status — all in the PBX's own public schema, live, with no portal involvement at all.
A portal copy in lubb would therefore be wrong the moment anybody cleaned a room: the board would show what reception last typed while the switch held what housekeeping last dialled. So the app reaches public directly, over its own narrowly-granted role.
The grant is the boundary
lubb_hotel (settings.HOTEL_DSN) holds:
| Object | Grant |
|---|---|
hotel_rooms, hotel_room_status, hotel_housekeeping_definitions | SELECT, INSERT, UPDATE, DELETE |
v_extensions | SELECT + UPDATE on directory_first_name, directory_last_name, effective_caller_id_name only |
v_voicemails | SELECT + UPDATE on voicemail_enabled only |
v_voicemail_messages, v_voicemail_greetings | SELECT, DELETE |
wakeup_calls | SELECT, INSERT, DELETE + UPDATE on the scheduling columns |
v_domains, v_domain_settings, v_default_settings | SELECT |
Nothing else on the PBX schema is reachable, and that is enforced by Postgres rather than by the module's good intentions — UPDATE v_extensions SET do_not_disturb raises permission denied. This is the same shape as app/services/wakeup_calls.py, which is the precedent for "the portal genuinely needs to write a PBX table".
What is deliberately not ported
- Do Not Disturb. The Laravel service sets
do_not_disturbon check-out. DND was retired platform-wide on 2026-08-12 — thedo-not-disturbdialplan is disabled, so FreeSWITCH never reads the column and a write here would be a silent no-op that reads on the board as a barred room. The note left at the time said a revived hotel module needs a different mechanism, so this port does not pretend to have one: a vacated room is renamed and has voicemail off, but it is not call-barred, andcheck_outreturnscall_barred: falseso the console says so rather than letting reception infer a restriction that isn't there. - Voicemail files. The rows go (so the box reads empty and the MWI clears); the recordings on the PBX's filesystem are left to the box's own retention sweep — which is worth checking before relying on, see
project_pbx_retention_globs_never_matched. - Reservations.
App\Models\HotelReservationhas no table on this box. It was never migrated; there was nothing to port. - The Char PMS webhook. A separate integration with its own inbound auth. The operations it would drive are the service functions, so adding it later is a router, not a rewrite.
Wake-up calls
The dialling half already exists and is not duplicated. FSPBX's ProcessWakeupCalls job runs every minute under Horizon (both live on this box) and hands each due row to ExecuteWakeUpCall, which originates to the room and runs lua/wakeup_call.lua — the IVR that owns snooze and completion. The portal's own app/services/wakeup_calls.py is a parallel implementation of the same loop, switched off here (WAKEUP_DSN unset) and deliberately left that way: two dispatchers claiming the same rows is a race worth not having when one of them already works.
So the app only has to write rows that dispatcher will pick up. Its selection is
status IN ('scheduled','snoozed') AND next_attempt_at <= now() AND retry_count <= 3which is why a new alarm sets next_attempt_at equal to wake_up_time and resets retry_count: a row with a null next_attempt_at is never due, and one with a spent retry count is skipped forever.
The clock
Times are stored as UTC in a timestamp without time zone column, because the process that reads them is FSPBX's PHP running app.timezone = 'UTC'. The console sends a browser-supplied instant with its offset, so nothing has to guess whose "06:30" is meant.
Two things follow that are worth knowing:
- The Postgres server is
Africa/Johannesburg, not UTC. Anything comparing these columns against SQLnow()reads every alarm as due two hours early. The PHP dispatcher is unaffected (itsnow()is PHP's); the portal's dormant one is not, and carries a warning at the top of the file saying so. - A hotel's own PBX time zone comes from
v_domain_settings/v_default_settings(time_zone), and on this box that resolves toUTCwith no domain overrides. The console's scheduling is unaffected, but the PBX's own paths — the wake-up IVR, the PMS webhook — read that setting, so a Johannesburg hotel left on UTC will have those two hours out. The Room Board says so on screen rather than leaving it to be discovered by a guest.
Checking a guest in or out clears that room's wake-up calls, so a new guest never inherits the last one's alarm.
Who can see which hotel
A hotel is a PBX domain here, so the app reuses the Cloud PBX app's own _domain_scope dependency rather than writing a second interpretation: staff and superusers are unrestricted, and on the customer portal a login is restricted to the domains their org actually owns. Every endpoint that names a domain is authorised before the service sees it — including the reads, because a room list carries guest names — and a room addressed by uuid is authorised against the domain on its own row.
API reference
All routes are under /hotel, Depends(require_role("admin")), and domain-scoped as above.
| Method | Path | Purpose |
|---|---|---|
| GET | /hotel/domains | Hotels this caller may run, with room counts |
| GET | /hotel/board | Every room, guest and housekeeping state — one read |
| GET | /hotel/extensions | Extension picker, flagged with the room holding each |
| POST/PATCH/DELETE | /hotel/rooms[/{uuid}] | Room ↔ extension mapping |
| POST | /hotel/rooms/{uuid}/check-in | Guest in; renames the phone, clears the box |
| POST | /hotel/rooms/{uuid}/check-out | Guest out; renames to Vacant |
| POST | /hotel/rooms/{uuid}/move | Move a guest to another room |
| POST | /hotel/rooms/{uuid}/housekeeping | Set the status from the console |
| GET | /hotel/wakeups | Every wake-up call still due on this hotel |
| POST | /hotel/rooms/{uuid}/wakeups | Set (or re-arm) a wake-up call |
| DELETE | /hotel/wakeups/{uuid} | Cancel one |
| GET/POST/PATCH/DELETE | /hotel/housekeeping-codes[/{uuid}] | The *26 code list |
Check-in, check-out, move, wake-up schedule/cancel and room create/delete write an AuditLog row — these rename a phone, wipe a voicemail box and delete alarms, and somebody will one day ask who did.
Setup
- Create the
lubb_hotelrole with the grants above and setHOTEL_DSN. Without it every page shows a clear "not configured" panel rather than empty tables. - Install the app on the portal site and add rooms under Hotel PBX → Rooms, mapping each to the extension physically in that room. A room with no extension never sees a
*26update. - Housekeeping codes seed themselves the first time a hotel is opened — deliberately, because the Lua looks codes up per domain with no global fallback, so an unseeded domain answers every
*26with "code not defined", which reads as the feature being broken rather than unconfigured.