RoomVox is a CalDAV-native room booking system for Nextcloud. This document describes the system architecture, data flow, and key design decisions.
┌─────────────────────────────────────────────────────────────┐
│ Calendar Clients │
│ NC Calendar · Apple Calendar · Outlook · Thunderbird │
└──────────────────────┬──────────────────────────────────────┘
│ CalDAV / iTIP
▼
┌─────────────────────────────────────────────────────────────┐
│ Nextcloud + Sabre DAV │
│ │
│ ┌──────────────┐ ┌───────────────────┐ │
│ │ RoomBackend │ │ SchedulingPlugin │ │
│ │ (IBackend) │ │ (priority 99) │ │
│ └──────┬───────┘ └────────┬──────────┘ │
│ │ │ │
│ ┌──────▼───────┐ ┌────────▼──────────┐ │
│ │ Room │ │ CalDAVService │ │
│ │ (IRoom) │ │ │ │
│ └──────────────┘ └───────────────────┘ │
│ │
│ ┌──────────────┐ ┌───────────────────┐ │
│ │ RoomService │ │ PermissionService │ │
│ └──────────────┘ └───────────────────┘ │
│ │
│ ┌──────────────┐ ┌───────────────────┐ │
│ │ MailService │ │ RoomUserBackend │ │
│ └──────────────┘ └───────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ IAppConfig │ │
│ │ rooms_index · room/{id} · permissions/{id} · ... │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Files: lib/Connector/Room/RoomBackend.php, lib/Connector/Room/Room.php
RoomVox implements Nextcloud's IBackend interface to expose rooms as CalDAV resources. When calendar apps query for available resources, the RoomBackend returns Room objects with metadata (capacity, type, address, facilities).
The Room class implements IRoom and publishes CalDAV properties:
| DAV Property | Source | IRoomMetadata constant |
|---|---|---|
{urn:ietf:params:xml:ns:caldav}calendar-description |
Formatted room description | — |
{http://nextcloud.com/ns}room-type |
Room type ID | ROOM_TYPE |
{http://nextcloud.com/ns}room-seating-capacity |
Capacity number | CAPACITY |
{http://nextcloud.com/ns}room-building-address |
Room address, empty segments dropped | BUILDING_ADDRESS |
{http://nextcloud.com/ns}room-building-story |
Floor | BUILDING_STORY |
{http://nextcloud.com/ns}room-building-name |
Building field | none — see below |
{http://nextcloud.com/ns}room-building-room-number |
Room number | BUILDING_ROOM_NUMBER |
{http://nextcloud.com/ns}room-features |
Comma-separated facility IDs | FEATURES |
Room visibility in calendar apps is controlled via group_restrictions derived from group entries in the permission system.
room-building-name is the one property with no constant behind it. Nextcloud's
OCP\Calendar\Room\IRoomMetadata defines keys for a room's address, story and
number, but not for the name of the building it is in — so clients that group
rooms per building have to guess one from the address, usually the segment
before the first comma. That breaks whenever the building field is empty: on a
113-room test instance, 51 rooms would group under a postal code rather than a
building.
Since 1.3.0 RoomVox publishes the key as a plain string so clients that
understand it can use real data; clients that do not simply ignore it. The
constant has been proposed upstream in
nextcloud/server#63244. If it
is accepted, replace the literal with IRoomMetadata::BUILDING_NAME — the key
string itself does not change, so this is a readability change, not a
behavioural one.
Related: nextcloud/calendar#8264 adds a room browser that groups rooms per building and currently carries the address heuristic this property is meant to replace.
File: lib/Dav/SchedulingPlugin.php
The heart of RoomVox. A Sabre DAV ServerPlugin registered at priority 99 (before Sabre's default handler at priority 100) that intercepts iTIP scheduling messages.
iTIP REQUEST arrives
│
├─ 1. Permission Check
│ Resolve sender → NC user → canBook()?
│ If no permission → DECLINE (3.7)
│
├─ 2. Availability Check
│ Event time within room's availability rules?
│ If outside hours → DECLINE (3.7)
│
├─ 3. Booking Horizon Check
│ Event (incl. recurrences) within max days?
│ If too far ahead → DECLINE (3.7)
│
├─ 4. Conflict Detection
│ Overlapping accepted/tentative bookings?
│ If conflict → DECLINE (3.0) + sendConflict()
│
├─ 5. PARTSTAT Determination
│ autoAccept? → ACCEPTED : TENTATIVE
│
├─ 6. Attendee Enrichment
│ Fix CUTYPE (iOS), add LOCATION
│
├─ 7. Deliver to Room Calendar
│ Store in room's CalDAV calendar
│
├─ 8. Set Schedule Status → 1.2 (delivered)
│
└─ 9. Notifications
ACCEPTED → sendAccepted() to organizer
TENTATIVE → notifyManagers()
iTIP CANCEL arrives
│
├─ 1. Delete from room calendar
├─ 2. Set schedule status → 1.2
└─ 3. sendCancelled() to organizer + managers
After any .ics file write, the plugin fixes the organizer's copy:
- Sets
CUTYPE=ROOMon room attendees (fixes iOS) - Writes back the correct
PARTSTAT(since we handle delivery, not Sabre) - Handles eM Client: detects rooms by LOCATION match and adds as ATTENDEE
Sabre's default scheduling handler runs at priority 100 and requires getPrincipalByUri() to resolve room principals, which needs an active user session. Since room principals use virtual service accounts (rb_*), Sabre's handler would fail. RoomVox intercepts first, handles delivery, and returns false to stop Sabre from attempting its own delivery.
File: lib/UserBackend/RoomUserBackend.php
Each room has a virtual user account with the rb_ prefix (e.g., rb_meeting-room-1). These accounts:
- Are registered with Nextcloud for CalDAV principal resolution
- Have display names matching the room name
- Are hidden from user search and listings
- Cannot be used to log in (password check always fails)
- Are not counted as real users
This design lets rooms have CalDAV principals (principals/users/rb_*) without creating real user accounts.
File: lib/Service/RoomService.php
RoomVox uses no custom database tables. All data is stored via Nextcloud's IAppConfig:
| Key Pattern | Content |
|---|---|
rooms_index |
JSON array of all room IDs |
room/{roomId} |
Room configuration JSON |
permissions/{roomId} |
Room permission JSON |
room_groups_index |
JSON array of all group IDs |
group/{groupId} |
Room group configuration JSON |
group_permissions/{groupId} |
Group permission JSON |
defaultAutoAccept |
Boolean string ('true'/'false') |
emailEnabled |
Boolean string |
roomTypes |
JSON array of type objects |
{
"id": "meeting-room-1",
"userId": "rb_meeting-room-1",
"name": "Meeting Room 1",
"email": "meeting-room-1@roomvox.local",
"roomNumber": "2.17",
"address": "Main Building, Kerkstraat 10, Amsterdam",
"roomType": "meeting-room",
"capacity": 10,
"description": "Corner room with projector",
"facilities": ["projector", "whiteboard", "videoconf"],
"autoAccept": true,
"active": true,
"groupId": "building-a",
"availabilityRules": {
"enabled": true,
"rules": [
{ "days": [1,2,3,4,5], "startTime": "08:00", "endTime": "18:00" }
]
},
"maxBookingHorizon": 90,
"calendarUri": "room-rb_meeting-room-1",
"smtpConfig": {
"host": "smtp.company.com",
"port": 587,
"username": "room1@company.com",
"password": "encrypted...",
"encryption": "tls"
},
"createdAt": "2026-01-15T10:30:00+00:00"
}- Zero migration overhead — no schema changes needed
- Simple deployment — no database setup required
- Room count is typically small (tens to hundreds), making key-value storage efficient
- Permissions and settings are naturally document-shaped (JSON)
- Booking data is stored in CalDAV calendars, not in RoomVox storage
File: lib/Service/PermissionService.php
Three-role hierarchy with user and group entries:
Manager > Booker > Viewer
Permissions are stored at two levels:
- Room-level — specific to a single room
- Group-level — inherited by all rooms in the group
Effective permissions are the union of both levels. Nextcloud administrators always have full access.
File: lib/Service/MailService.php
Sends transactional emails for booking events:
- Per-room SMTP: Uses Symfony Mailer directly with the room's SMTP config
- Global fallback: Uses Nextcloud's IMailer
- SMTP passwords are encrypted with
ICryptobefore storage - Internal
@roomvox.localemails are not used as sender addresses
File: lib/Service/CalDAVService.php
Interface to Nextcloud's CalDAV backend for:
- Calendar provisioning — creating/deleting room calendars
- Booking CRUD — creating, reading, updating, deleting events
- Conflict detection — checking for time overlaps
- Availability publishing — VAVAILABILITY objects for room availability rules
File: lib/AppInfo/Application.php
Registration phase:
- Register
RoomBackendas CalDAV room backend - Register
SabrePluginListenerfor Sabre plugin injection
Boot phase:
- Register
RoomUserBackendwith user manager - Wire late injection to break circular dependency between PermissionService and RoomService
- Vue 3 with Composition API
- Nextcloud Vue component library (
@nextcloud/vue) - Webpack build via
@nextcloud/webpack-vue-config
src/
├── main.js # Entry point, mounts to #app-roomvox
├── App.vue # Tab navigation (Rooms, Bookings, Settings)
├── views/
│ ├── RoomList.vue # Room table with search and filters
│ ├── RoomEditor.vue # Room create/edit form
│ ├── PermissionEditor.vue # User/group permission assignment
│ └── BookingOverview.vue # Booking list with approve/decline
└── services/
└── api.js # Axios-based API client
The admin panel is rendered inside Nextcloud's settings framework (/settings/admin/roomvox), mounted to a plain <div id="app-roomvox"> without NcContent/NcAppContent wrappers.