Files
calendars/docs/resources.md
Sylvain Zimmer 9c18f96090 (all) add organizations, resources, channels, and infra migration (#34)
Add multi-tenant organization model populated from OIDC claims with
org-scoped user discovery, CalDAV principal filtering, and cross-org
isolation at the SabreDAV layer.

Add bookable resource principals (rooms, equipment) with CalDAV
auto-scheduling that handles conflict detection, auto-accept/decline,
and org-scoped booking enforcement. Fixes #14.

Replace CalendarSubscriptionToken with a unified Channel model
supporting CalDAV integration tokens and iCal feed URLs, with
encrypted token storage and role-based access control. Fixes #16.

Migrate task queue from Celery to Dramatiq with async ICS import,
progress tracking, and task status polling endpoint.

Replace nginx with Caddy for both the reverse proxy and frontend
static serving. Switch frontend package manager from yarn/pnpm to
npm and upgrade Node to 24, Next.js to 16, TypeScript to 5.9.

Harden security with fail-closed entitlements, RSVP rate limiting
and token expiry, CalDAV proxy path validation blocking internal
API routes, channel path scope enforcement, and ETag-based
conflict prevention.

Add frontend pages for resource management and integration channel
CRUD, with resource booking in the event modal.

Restructure CalDAV paths to /calendars/users/ and
/calendars/resources/ with nested principal collections in SabreDAV.
2026-03-09 09:09:34 +01:00

48 KiB

Calendar Resources

This document describes the design and implementation plan for calendar resources in La Suite Calendars: meeting rooms, vehicles, projectors, and any other bookable assets that people can reserve alongside events.

Table of Contents


Overview

Calendar resources allow organizations to manage shared physical assets (rooms, vehicles, equipment) through the calendar. Users book resources by adding them as attendees to events, just like inviting a person. The system handles availability checking, conflict prevention, and automatic accept/decline responses.

Key principles:

  • Resources are modeled as CalDAV principals, following RFC 6638
  • Any CalDAV client can book a resource by inviting its email address
  • The server handles auto-scheduling (accept if free, decline if busy)
  • Resource provisioning (create/delete) happens through Django; all other management through CalDAV
  • Double-booking prevention is enforced server-side

What Is a Calendar Resource?

A calendar resource is a bookable entity that is not a person. Resources fall into two categories defined by the iCalendar standard (RFC 5545):

Type CUTYPE Examples
Room ROOM Conference rooms, meeting rooms, auditoriums, phone booths
Resource RESOURCE Projectors, vehicles, cameras, whiteboards, parking spots

Each resource has:

  • A display name (e.g., "Room 101 - Large Conference")
  • A scheduling address (a mailto: URI used as a CalDAV identifier -- see below)
  • A calendar that shows its bookings
  • Metadata (capacity, location, description, equipment list)
  • Availability hours (e.g., 8:00-20:00 on weekdays)
  • An auto-schedule policy (auto-accept, require approval, etc.)
  • One or more administrators who manage the resource

Resource Scheduling Addresses (Not Real Emails)

In CalDAV, every principal (user or resource) is identified by a mailto: URI in the calendar-user-address-set property. This is how the scheduling protocol matches an ATTENDEE on an event to a principal on the server. These addresses do not need to be real, routable email addresses.

When SabreDAV receives a scheduling request for mailto:c_a1b2c3d4@resource.calendar.example.com, it looks up the email column in the principals table. If a matching principal is found locally, the iTIP message is delivered internally (inbox-to-inbox on the same server). See Email Safety for how outbound emails to resource addresses are prevented.

Recommended convention: {opaque-id}@resource.calendar.{APP_DOMAIN}

The address uses:

  • An opaque identifier (UUID or short hash, prefixed with c_) rather than a human-readable slug, so renaming a resource doesn't change its address
  • A subdomain you control (resource.calendar.{APP_DOMAIN}), which avoids collisions with real user emails and leaves the door open for future inbound email (e.g., resources acting as meeting organizers)

Examples (with APP_DOMAIN=example.com):

  • c_a1b2c3d4@resource.calendar.example.com
  • c_f6g7h8i9@resource.calendar.example.com

By default, no MX record is configured for this subdomain, so inbound email silently fails -- same practical effect as a non-routable address, but reversible if resources need to send/receive email in the future (e.g., room-initiated meetings with external attendees).

Org scoping is handled at the CalDAV/application level, not encoded in the email address.

The system identifies resource principals via the calendar_user_type column in the SabreDAV principals table, not by email pattern.

Email Safety

Resource scheduling addresses are not real mailboxes. No email should ever be sent to or from them. However, the current invitation system (HttpCallbackIMipPlugin -> Django -> email) will attempt to email any attendee unless explicitly prevented. This must be handled at two levels:

Internal: Preventing Outbound Emails to Resources

In the current flow, when a user adds attendees to an event, HttpCallbackIMipPlugin POSTs to Django for each attendee, and Django sends an invitation email. Without intervention, Django would attempt to email the resource address.

Fix 1 (SabreDAV): The ResourceAutoSchedulePlugin (priority 120) sets $message->scheduleStatus on the iTIP message before HttpCallbackIMipPlugin runs. The base IMipPlugin class skips messages that already have a status set, preventing the callback entirely.

Fix 2 (Django, safety net): CalendarInvitationService.send_invitation() checks if the recipient is a resource address (by checking calendar_user_type of the principal or matching the @resource.calendar.example.com domain) and skips email sending.

Both should be implemented. The plugin is the primary gate; Django is the fallback.

External: What Happens When Outside Systems See the Address

When an event includes both a resource and external attendees, the ICS attachment in invitation emails lists all ATTENDEE properties, including the resource's mailto:c_abc123@resource.calendar.example.com. This is visible to external recipients. Here's why this is safe:

Scenario What happens Risk
External email client tries to send iTIP REPLY REPLY goes to ORGANIZER only (the human), not to other attendees None
External email client tries to contact all attendees Email to @resource.calendar.example.com bounces (no MX record by default) None
External CalDAV server tries to book the resource iTIP REQUEST email to @resource.calendar.example.com bounces None -- resources are only bookable through this server
CalDAV client connected to THIS server Uses SCHEDULE-AGENT=SERVER; all scheduling goes through SabreDAV, no email None

By default no MX record exists for resource.calendar.{APP_DOMAIN}, so external email attempts bounce harmlessly. All legitimate resource interactions go through the CalDAV server. If resources need to send/receive email in the future (e.g., room-initiated meetings), an MX record can be added and inbound mail routed to the application.


CalDAV Standards and Interoperability

Relevant Standards

Standard Role
RFC 5545 (iCalendar) Defines CUTYPE parameter (ROOM, RESOURCE) on ATTENDEE properties
RFC 6638 (CalDAV Scheduling) Defines calendar-user-type DAV property on principals; scheduling transport (inbox/outbox); SCHEDULE-AGENT parameter
RFC 4791 (CalDAV) Calendar collections, free-busy-query REPORT
RFC 7953 (Calendar Availability) VAVAILABILITY component for defining operating hours (future)
draft-cal-resource-schema Resource metadata schema (capacity, booking window, manager, etc.)
draft-pot-caldav-sharing Calendar sharing between principals

How Resources Work in CalDAV

In CalDAV, a resource is a regular principal with a special calendar-user-type property set to ROOM or RESOURCE. It has its own calendar home, schedule inbox, and schedule outbox, exactly like a user principal.

When a user creates an event with a resource as an attendee:

ATTENDEE;CUTYPE=ROOM;ROLE=NON-PARTICIPANT;PARTSTAT=NEEDS-ACTION;
 RSVP=TRUE:mailto:c_a1b2c3d4@resource.calendar.example.com

The CalDAV scheduling server (RFC 6638):

  1. Detects the ATTENDEE on the event
  2. Delivers an iTIP REQUEST to the resource's schedule inbox
  3. A server-side agent checks the resource's calendar for conflicts
  4. Sends an iTIP REPLY back with PARTSTAT=ACCEPTED or PARTSTAT=DECLINED

Auto-scheduling is not standardized -- it is a server implementation feature. RFC 6638 only defines the transport mechanism. This project implements auto-scheduling as a custom SabreDAV plugin (see What SabreDAV Provides for rationale).

What This Means for Interoperability

The critical insight: resource booking does not require client-side support. Any CalDAV client that can add an attendee by email address can book a resource. The server handles everything else. This means:

  • Apple Calendar, Thunderbird, GNOME Calendar, and all CalDAV clients work out of the box
  • Clients that support CUTYPE can display resources differently from people
  • Clients that support principal-property-search can discover available resources
  • The web frontend provides the richest experience (resource browser, availability view)

Data Model

Design Principle: CalDAV as Single Source of Truth

Resources are entirely managed in CalDAV -- metadata, calendar data, and access control. No Django model is needed.

  • Metadata (name, capacity, location, equipment, etc.): DAV properties via PROPFIND/PROPPATCH
  • Calendar data (bookings, free/busy): CalDAV calendar collections
  • Access control: CalDAV sharing (CS:share) with privilege levels, the same mechanism already used for user calendar sharing
  • Scheduling config (auto-schedule mode, booking policies): Custom DAV properties read by the SabreDAV plugin

Django's role is limited to:

  • Provisioning: A REST endpoint to create/delete resource principals. CalDAV has no standard operation to create a principal, so this is the one justified exception to the "Django is a pass-through" rule. Django makes CalDAV requests to SabreDAV to set up the principal + calendar + initial properties.
  • Proxying: The existing CalDAV proxy (CalDAVProxyView) forwards all CalDAV requests, including those for resource principals

This means zero new Django models for resources. The frontend manages resource permissions via CalDavService.shareCalendar() / getCalendarSharees(), exactly like it already does for user calendars.

Resource as a CalDAV Principal

Each resource exists as a principal in SabreDAV with a single dedicated calendar:

principals/resources/{resource-id}
  -> calendar-home-set: /calendars/resources/{resource-id}/
  -> schedule-inbox-URL: /calendars/resources/{resource-id}/inbox/
  -> schedule-outbox-URL: /calendars/resources/{resource-id}/outbox/
  -> calendar-user-type: ROOM | RESOURCE
  -> calendar-user-address-set: mailto:{opaque-id}@resource.calendar.example.com

The mailto: address is a CalDAV internal identifier, not a real mailbox (see Resource Scheduling Addresses).

A resource principal has exactly one calendar. Although CalDAV allows any principal to own multiple calendar collections, this doesn't make sense for resources (a room has one schedule). MKCALENDAR requests targeting a resource principal's calendar home are rejected by a SabreDAV plugin (hooking beforeMethod:MKCALENDAR). The single calendar is created during provisioning and cannot be added to or removed independently.

Resource Properties (CalDAV)

All resource metadata is stored as DAV properties on the resource's principal or default calendar, using standard properties where they exist and a project namespace ({urn:lasuite:calendars}) for the rest.

SabreDAV's PropertyStorage plugin persists custom properties in a propertystorage table automatically -- any property set via PROPPATCH is stored and returned via PROPFIND.

Standard Properties (on the principal or calendar collection)

Property Namespace Where Description
displayname {DAV:} Principal Resource name
calendar-user-type {urn:ietf:params:xml:ns:caldav} Principal ROOM or RESOURCE
calendar-description {urn:ietf:params:xml:ns:caldav} Calendar Free-form description
calendar-color {http://apple.com/ns/ical/} Calendar Hex color (e.g., #4CAF50)
calendar-timezone {urn:ietf:params:xml:ns:caldav} Calendar VTIMEZONE component

Semi-Standard Properties (Apple CalendarServer)

Property Namespace Where Description
capacity {http://calendarserver.org/ns/} Principal Integer, number of seats/units

Custom Properties (Project Namespace)

Property Namespace Where Type Description
location {urn:lasuite:calendars} Principal String Building, floor, address
equipment {urn:lasuite:calendars} Principal JSON array ["Projector", "Whiteboard"]
tags {urn:lasuite:calendars} Principal JSON array ["building-a", "video"]
auto-schedule-mode {urn:lasuite:calendars} Principal String See auto-schedule modes below
is-active {urn:lasuite:calendars} Principal Boolean true / false
restricted-access {urn:lasuite:calendars} Principal Boolean Restrict booking to explicit access
multiple-bookings {urn:lasuite:calendars} Principal Integer Max concurrent bookings (1 = no overlap)
max-booking-duration {urn:lasuite:calendars} Principal Duration ISO 8601 (e.g., PT4H)
booking-window-start {urn:lasuite:calendars} Principal Duration How far ahead (e.g., P90D)
booking-window-end {urn:lasuite:calendars} Principal Duration Minimum notice (e.g., PT1H)

Example: Reading Resource Properties

PROPFIND /principals/resources/room-101/
<?xml version="1.0" encoding="utf-8"?>
<D:propfind xmlns:D="DAV:"
    xmlns:C="urn:ietf:params:xml:ns:caldav"
    xmlns:A="http://apple.com/ns/ical/"
    xmlns:CS="http://calendarserver.org/ns/"
    xmlns:LS="urn:lasuite:calendars">
  <D:prop>
    <D:displayname/>
    <C:calendar-user-type/>
    <CS:capacity/>
    <LS:location/>
    <LS:equipment/>
    <LS:tags/>
    <LS:auto-schedule-mode/>
    <LS:is-active/>
  </D:prop>
</D:propfind>

Example: Setting Resource Properties

PROPPATCH /principals/resources/room-101/
<?xml version="1.0" encoding="utf-8"?>
<D:propertyupdate xmlns:D="DAV:"
    xmlns:CS="http://calendarserver.org/ns/"
    xmlns:LS="urn:lasuite:calendars">
  <D:set>
    <D:prop>
      <CS:capacity>20</CS:capacity>
      <LS:location>Building A, Floor 2</LS:location>
      <LS:equipment>["Projector", "Whiteboard", "Video conferencing"]</LS:equipment>
      <LS:tags>["video-conference", "whiteboard", "building-a"]</LS:tags>
      <LS:auto-schedule-mode>automatic</LS:auto-schedule-mode>
    </D:prop>
  </D:set>
</D:propertyupdate>

Auto-Schedule Modes

Stored as the {urn:lasuite:calendars}auto-schedule-mode property on the resource principal. Inspired by Apple Calendar Server's proven model:

Mode Behavior
automatic Accept if free, decline if busy (default)
accept-always Accept all invitations regardless of conflicts
decline-always Decline all invitations (resource offline)
manual Require a resource manager to accept/decline

The ResourceAutoSchedulePlugin reads this property from SabreDAV's propertystorage table (same PostgreSQL instance) during scheduling -- no Django roundtrip needed.

Resource Access (CalDAV Sharing)

Resource access uses the same CalDAV sharing mechanism (CS:share) already used for user calendar sharing. No Django model is needed.

The resource's calendar is shared with administrators/managers using the existing privilege levels:

CalDAV Privilege Role Can Do
read Viewer See bookings, free/busy
read-write Manager See bookings, modify/cancel any booking
admin Admin Full control: edit properties, manage sharing, override bookings

The frontend manages this via CalDavService.shareCalendar() and getCalendarSharees() -- the same code already used for sharing user calendars.

The resource creator is automatically the calendar owner (the principal itself owns the calendar collection). Additional admins are added via sharing.

What About Search and Filtering?

Storing metadata in CalDAV raises the question: how do you filter resources by capacity, tags, or location?

For most deployments, the number of resources is small (tens to low hundreds). The frontend can:

  1. PROPFIND on principals/resources/ to fetch all resource principals with their properties in a single request
  2. Filter and sort client-side in JavaScript (capacity >= 10, tags include "projector", etc.)

This is simple, avoids data duplication, and works well up to ~1000 resources. If a deployment needs SQL-level search across thousands of resources, a Django read-only index synced from CalDAV can be added later as an optimization -- but this is not needed for v1.


Resource Lifecycle

Creating a Resource

  1. An org admin (can_admin entitlement) calls the Django REST API to create a resource (name, type)
  2. Django calls SabreDAV's /internal-api/resources/ endpoint (POST with JSON body). The InternalApiPlugin handles both principal creation and default calendar creation atomically. The admin's org_id is passed in the request body.
  3. The frontend sets additional metadata via PROPPATCH (capacity, location, equipment, etc.)
  4. The resource is immediately available for booking

No Django model is created -- the CalDAV principal is the resource.

Architecture note: All resource CRUD goes through the /internal-api/ namespace in SabreDAV, which is completely separate from the CalDAV protocol namespace. This avoids direct database access from Django to SabreDAV tables. The internal API is gated by the X-Internal-Api-Key header (different from the X-Api-Key used by the CalDAV proxy) and is explicitly blocked by the Django proxy's path validation.

Updating a Resource

All metadata changes go through CalDAV PROPPATCH directly (from the frontend or via a Django proxy endpoint). There is no Django model to keep in sync.

  • Display properties (name, color, description): PROPPATCH on the calendar collection
  • Resource properties (capacity, equipment, location, tags): PROPPATCH on the principal
  • Scheduling config (auto-schedule-mode, booking policies): PROPPATCH on the principal
  • Deactivating a resource: set {urn:lasuite:calendars}is-active to false

Deleting a Resource

  1. Django calls DELETE /internal-api/resources/{resource-id} on SabreDAV
  2. The InternalApiPlugin deletes all calendars, calendar objects, scheduling objects, and the principal row atomically
  3. Sharing entries are automatically cleaned up with the calendar

Existing events in user calendars that reference the resource as an attendee are left as-is. The resource's mailto: address becomes an unresolvable address -- same as if an external attendee disappeared. The resource will simply stop responding to scheduling requests. This avoids the complexity of modifying events across all user calendars.


Booking Flow

Standard Booking (Any CalDAV Client)

User                    CalDAV Server              Auto-Scheduler
  |                          |                          |
  |-- PUT event ------------>|                          |
  |   (ATTENDEE=c_...@resource.calendar.example.com)
  |                          |-- iTIP REQUEST --------->|
  |                          |                          |-- check free/busy
  |                          |                          |-- no conflict?
  |                          |<-- iTIP REPLY -----------|
  |                          |   (PARTSTAT=ACCEPTED)    |
  |<-- schedule-status ------|                          |
  |    (1.2 = delivered)     |                          |

Web Frontend Booking

The web UI provides a richer experience:

  1. User opens event creation modal
  2. User clicks "Add Room" or "Add Resource"
  3. A resource picker shows available resources with metadata
  4. User selects a resource; frontend checks free/busy in real-time
  5. Frontend adds the resource as an ATTENDEE with CUTYPE=ROOM
  6. On save, the CalDAV flow triggers auto-scheduling
  7. The event updates with the resource's PARTSTAT response

Booking with Conflicts

When a resource is already booked:

  1. User creates event with resource as attendee
  2. Auto-scheduler detects conflict
  3. Resource declines (PARTSTAT=DECLINED)
  4. Organizer sees the declined status
  5. Frontend shows a warning: "Room 101 is unavailable at this time"

Recurring Event Booking

For recurring events, the auto-scheduler must check every instance within a reasonable window (e.g., 1 year) for conflicts. If any instance conflicts:

  • Option A (strict): Decline the entire series
  • Option B (lenient): Accept the series but decline specific instances via EXDATE

The recommended approach is Option A for simplicity, with the UI helping users find conflict-free times.


What SabreDAV Provides (and What It Doesn't)

SabreDAV is the most mature open-source CalDAV server, but its support for resource principals (ROOM/RESOURCE) has gaps. Understanding exactly what's built in vs. what we extend is important.

CUTYPE: Extensible but Not Enabled by Default

RFC 6638 §2.4.2 defines the {urn:ietf:params:xml:ns:caldav}calendar-user-type property on principals, with values from RFC 5545's CUTYPE parameter (INDIVIDUAL, ROOM, RESOURCE, GROUP, etc.). SabreDAV acknowledges this property but doesn't fully implement it:

  • The Schedule\Plugin hardcodes calendar-user-type to 'INDIVIDUAL' for all principals, with an inline comment: "The server currently reports every principal to be of type INDIVIDUAL."
  • The PrincipalBackend\PDO base class only maps two columns in its $fieldMap: displayname and email. There is no calendar_user_type column in the default schema.

However, SabreDAV was designed for this to be extended:

  1. The $fieldMap in PrincipalBackend\PDO is a protected property that subclasses can override to add custom DB columns and map them to WebDAV properties.
  2. When a property is in the $fieldMap, the Principal node exposes it via getProperties(), which runs before the Schedule Plugin's handle() callback.
  3. The Schedule Plugin uses $propFind->handle() which is a no-op when the property is already set. So the hardcoded 'INDIVIDUAL' only serves as a fallback for principals that lack the column.

This means adding CUTYPE support is a one-line extension of the fieldMap, not a hack:

// In our AutoCreatePrincipalBackend (extends PrincipalBackend\PDO)
protected $fieldMap = [
    '{DAV:}displayname' => ['dbField' => 'displayname'],
    '{http://sabredav.org/ns}email-address' => ['dbField' => 'email'],
    // This is all it takes -- SabreDAV handles the rest
    '{urn:ietf:params:xml:ns:caldav}calendar-user-type' => ['dbField' => 'calendar_user_type'],
];

With this in place, getPrincipalByPath(), getPrincipalsByPrefix(), and searchPrincipals() all automatically include the real CUTYPE value. CalDAV clients that support principal-property-search (Apple Calendar) can discover rooms and resources. PROPFIND on resource principals returns the correct type. No custom plugin is needed for CUTYPE -- just the standard extension point.

Auto-Scheduling: Not Built In Anywhere

While CUTYPE is a matter of data exposure (and SabreDAV provides the extension points), auto-scheduling is a different story. No existing CalDAV server component provides automatic accept/decline for resource principals:

SabreDAV Core: The Schedule\Plugin handles iTIP delivery (inbox/outbox) but has no logic to auto-accept or auto-decline based on calendar-user-type or availability. After delivering a scheduling message, it's done.

Nextcloud: Built resource scheduling on top of SabreDAV, but deeply coupled to the Nextcloud framework (\OCP\ interfaces, DI container, app framework). None of the code (ResourcePrincipalBackend.php, RoomPrincipalBackend.php, CalDavBackend.php) is extractable as a standalone plugin.

SOGo: Has resource auto-accept, but written in Objective-C with its own CalDAV stack -- not SabreDAV-based.

Bedework: The one enterprise CalDAV server with built-in room/resource scheduling, but it's a Java application, not a SabreDAV plugin.

Packagist / Open Source: No third-party SabreDAV resource scheduling package exists.

What This Means

  • CUTYPE support: Uses SabreDAV's built-in $fieldMap extension. One line of code, fully idiomatic.
  • Auto-scheduling: Requires a custom ResourceAutoSchedulePlugin (~350 lines of PHP). Follows the same pattern as our existing HttpCallbackIMipPlugin: hook into the schedule event, inspect the iTIP message, act on it. The plugin reads resource configuration from the shared PostgreSQL database directly (same instance SabreDAV already uses).

Auto-Scheduling and Conflict Detection

How Auto-Scheduling Works

Auto-scheduling is implemented as a custom SabreDAV plugin that intercepts scheduling deliveries to resource principals. It runs after Sabre\CalDAV\Schedule\Plugin delivers the iTIP message. No existing SabreDAV plugin provides this functionality (see What SabreDAV Provides).

class ResourceAutoSchedulePlugin extends ServerPlugin
{
    // Hook into the 'schedule' event
    function schedule(ITip\Message $message)
    {
        // 1. Is the recipient a resource principal?
        // 2. What is the resource's auto_schedule_mode?
        // 3. For 'automatic' mode: check free/busy
        // 4. Set $message->scheduleStatus accordingly
        // 5. Update PARTSTAT in the delivered calendar object
    }
}

Conflict Detection Algorithm

function hasConflict(resource, newEvent):
    // Get the resource's calendar
    calendar = resource.getDefaultCalendar()

    // For each instance of the new event (expand recurrence)
    for instance in expandInstances(newEvent, maxWindow=1year):
        if instance.transp == TRANSPARENT:
            continue  // transparent events don't block

        // Query existing events in this time range
        existing = calendar.getEvents(instance.start, instance.end)

        // Check against max concurrent bookings
        overlapping = countOverlapping(existing, instance)
        if overlapping >= resource.multiple_bookings:
            return true  // conflict

    return false  // no conflict

Edge Cases

  • All-day events: Treated as blocking the entire day
  • Tentative events: Count as busy (configurable per resource)
  • Cancelled events: Do not count as busy
  • Transparent events (TRANSP=TRANSPARENT): Do not count as busy
  • Recurring with exceptions: Must check each expanded instance

Free/Busy and Availability

Free/Busy Queries

Resources support standard CalDAV free/busy queries. Two methods:

Method 1: CALDAV:free-busy-query REPORT (RFC 4791)

REPORT /calendars/resources/room-101/default/
<?xml version="1.0" encoding="utf-8"?>
<C:free-busy-query xmlns:C="urn:ietf:params:xml:ns:caldav">
  <C:time-range start="20260305T000000Z" end="20260306T000000Z"/>
</C:free-busy-query>

Returns a VFREEBUSY component with busy intervals.

Method 2: Schedule outbox POST (RFC 6638)

The organizer POSTs a VFREEBUSY request to their outbox, specifying the resource as an attendee. The server returns the resource's free/busy data.

Availability Hours (VAVAILABILITY) — Future

Resources could define operating hours using RFC 7953 VAVAILABILITY (e.g., Monday-Friday 8:00-20:00, booking outside these hours auto-declined). However, SabreDAV does not support RFC 7953 out of the box — the auto-schedule plugin would need to implement VAVAILABILITY parsing. This is deferred to Phase 5.

Frontend Availability View

The web frontend shows:

  • A day/week timeline of the resource's bookings
  • Color-coded slots: free (green), busy (red), tentative (yellow)
  • A multi-resource view to compare several rooms side by side

Access Rights and Administration

Resource access control uses CalDAV sharing (CS:share), the same mechanism already used for sharing user calendars. No Django model is needed.

Privilege Levels

CalDAV Privilege Role Capabilities
(no share) Any authenticated user Discover resource, view free/busy, book by adding as attendee
read Shared viewer All of the above + see full booking details on the calendar
read-write Manager All of the above + modify/cancel any booking on the resource
admin Administrator All of the above + edit resource properties (PROPPATCH), manage sharing, override auto-schedule decisions, delete resource

How It Works

  • The resource principal owns its calendar collection. This is the "owner" in CalDAV terms.
  • Admins are added by sharing the resource's calendar with admin privilege via CS:share (same as CalDavService.shareCalendar())
  • Managers get read-write privilege
  • Viewers get read privilege
  • Any authenticated user can book the resource (add it as an attendee to their event) and query free/busy -- this does not require sharing. The CalDAV scheduling protocol handles this via the resource's schedule inbox.

This maps directly to how user calendar sharing already works in the frontend (CalendarShareModal, CalDavService.shareCalendar(), getCalendarSharees()).

Restricted Resources

For resources that should not be bookable by everyone (executive rooms, specialized equipment), the {urn:lasuite:calendars}restricted-access property can be set to true. When set, the auto-schedule plugin only accepts invitations from users who have been explicitly shared on the resource's calendar.

Organization Scoping and Permissions

See docs/organizations.md for the full org design. Key points for resources:

  • Resource discovery is org-scoped: SabreDAV filters resource principals by the org_id column on the principals table, using the X-CalDAV-Organization header set by Django.
  • Cross-org resource booking is not allowed: the auto-schedule plugin rejects invitations from users outside the resource's org.
  • Resource creation requires the can_admin entitlement (returned by the entitlements system alongside can_access).

Sharing and Delegation

Delegation to Resource Managers

When auto-schedule-mode=manual, incoming booking requests require approval:

  1. User creates event with resource as attendee
  2. Auto-scheduler detects manual mode
  3. Resource stays in PARTSTAT=NEEDS-ACTION
  4. Resource managers receive a notification
  5. Manager approves or declines via:
    • The web UI (resource management panel)
    • Direct calendar interaction (change PARTSTAT on the resource's calendar)
  6. iTIP REPLY sent back to the organizer

Resource Discovery

How Users Find Resources

In major calendar apps, users pick rooms from a list scoped to their organization. This works because those apps are tied to an organization directory (Workspace domain, Exchange GAL, etc.). In CalDAV, there are two discovery mechanisms:

RFC 3744 defines a DAV:principal-property-search REPORT that lets clients search for principals by property. For example, to find all rooms:

REPORT /principals/
<?xml version="1.0" encoding="utf-8"?>
<D:principal-property-search xmlns:D="DAV:"
    xmlns:C="urn:ietf:params:xml:ns:caldav">
  <D:property-search>
    <D:prop>
      <C:calendar-user-type/>
    </D:prop>
    <D:match>ROOM</D:match>
  </D:property-search>
  <D:prop>
    <D:displayname/>
    <C:calendar-user-address-set/>
  </D:prop>
</D:principal-property-search>

Limitations:

  • Returns all matching principals on the server -- there is no built-in scoping by organization or tenant
  • Only Apple Calendar uses this for resource discovery in practice
  • Thunderbird and GNOME Calendar do not support resource discovery via CalDAV
  • CalDAV properties are limited to basic fields (name, email, CUTYPE) -- no capacity, equipment, location metadata

In a single-tenant deployment, this works fine: all resources belong to the same organization, so returning all of them is correct.

In a multi-tenant deployment, SabreDAV filters results by the org_id on the principals table (see Organization Scoping).

Discovery via CalDAV + Client-Side Filtering (Web Frontend)

The web frontend fetches all resource principals via a single PROPFIND on principals/resources/ (through the CalDAV proxy) and filters/sorts client-side in JavaScript. This is the primary and richest discovery method:

  • Full metadata: capacity, equipment, location, tags
  • Client-side filtering: by type, capacity, location, tags
  • Real-time availability: via free-busy-query REPORT
  • Org-scoped: SabreDAV filters by org_id before returning

No Django endpoint is needed for resource discovery. The frontend does the same kind of PROPFIND it already does for calendars.

Discovery via Email (All Clients)

Any CalDAV client can book a resource by typing its scheduling address in the attendee field, even without a discovery UI. This is the universal fallback that works everywhere.

Interoperability with CalDAV Clients

Client Book by Email See CUTYPE Discover via CalDAV Free/Busy
Apple Calendar Yes Yes Yes (principal-property-search) Yes
Thunderbird Yes No (shows as person) No Yes
GNOME Calendar Yes Partial No Partial
Web Frontend Yes Yes Yes (PROPFIND + client-side) Yes
Any CalDAV client Yes Varies Varies Yes

The key takeaway: booking works universally (any client can invite a resource by email). Discovery (browsing available rooms) is richest in the web frontend and limited in native CalDAV clients.


Implementation Plan

Phase 1: Resource Principals in SabreDAV

Goal: Resources exist as CalDAV principals and can receive scheduling messages.

Changes:

  1. Extend SabreDAV principals table: Add calendar_user_type column (and org_id for multi-tenancy)
  2. Extend AutoCreatePrincipalBackend.$fieldMap: Add {urn:ietf:params:xml:ns:caldav}calendar-user-typecalendar_user_type. This is SabreDAV's idiomatic extension point for principal properties -- the base PrincipalBackend\PDO automatically includes mapped fields in all queries, and the Schedule\Plugin's hardcoded INDIVIDUAL becomes a fallback (see What SabreDAV Provides)
  3. Add nested principal prefixes: principals/users/ for user principals and principals/resources/ for resource principals, with a custom CalendarsRoot node to handle the nested structure (SabreDAV's default CalendarRoot only supports flat principal prefixes)
  4. Verify scheduling delivery: Ensure Schedule\Plugin delivers iTIP messages to resource inboxes

Files to modify:

  • src/caldav/sql/pgsql.principals.sql -- add columns
  • src/caldav/src/AutoCreatePrincipalBackend.php -- extend $fieldMap
  • src/caldav/src/CalendarsRoot.php -- custom DAV Collection for nested prefixes
  • src/caldav/server.php -- use CalendarsRoot and nested principal collections

Phase 2: Django Provisioning API

Goal: REST endpoint to create/delete resource principals, gated by org-level permissions.

Changes:

  1. Resource provisioning endpoint: Creates the SabreDAV principal + calendar via CalDAV requests (no direct DB writes). Checks the user's can_admin entitlement.
  2. Resource deletion endpoint: Cleans up CalDAV principal + calendar. Same permission check.

No Django model is needed. Metadata is managed via CalDAV PROPPATCH. Access control uses CalDAV sharing (CS:share), the same way user calendars are shared. Org-level permission to create/delete resources comes from the can_admin entitlement.

Files to create/modify:

  • src/backend/core/api/viewsets.py -- add resource provisioning viewset
  • src/backend/core/services/resource_service.py -- provisioning logic (CalDAV calls)

Phase 3: Auto-Scheduling Plugin

Goal: Resources automatically accept/decline based on availability.

No existing SabreDAV plugin provides this -- see What SabreDAV Provides. The plugin is ~350 lines of PHP, following the same pattern as the existing HttpCallbackIMipPlugin.

Changes:

  1. ResourceAutoSchedulePlugin.php: SabreDAV plugin that hooks into the schedule event (same hook HttpCallbackIMipPlugin already uses)
  2. Conflict detection: Query the resource's calendar for overlapping events
  3. Auto-schedule modes: Read the {urn:lasuite:calendars}auto-schedule-mode property from SabreDAV's propertystorage table (same PostgreSQL instance, no Django roundtrip)
  4. iTIP REPLY generation: Send ACCEPTED or DECLINED back to organizer
  5. Availability hours: Not in v1 (see Phase 5)

Files to create/modify:

  • src/caldav/src/ResourceAutoSchedulePlugin.php -- new plugin
  • src/caldav/server.php -- register plugin

Design choice: The plugin reads the auto_schedule_mode from the database directly (same PostgreSQL instance SabreDAV already uses) rather than calling Django's API, to avoid circular HTTP dependencies during scheduling.

Phase 4: Frontend Resource UI

Goal: Users can discover, browse, and book resources from the web interface.

Components:

  1. Resource directory: Searchable/filterable list of all resources
  2. Resource detail panel: Shows metadata, availability timeline, current bookings
  3. Resource picker in event modal: Add room/resource when creating an event
  4. Availability checker: Real-time free/busy display when selecting a resource
  5. Multi-resource timeline: Side-by-side availability comparison
  6. Resource management panel: For admins to create/edit/configure resources

Files to create:

  • src/frontend/apps/calendars/src/features/resources/ -- new feature module
    • types.ts -- TypeScript types for resource properties
    • components/ResourceDirectory.tsx
    • components/ResourcePicker.tsx
    • components/ResourceDetail.tsx
    • components/ResourceTimeline.tsx
    • components/ResourceAdmin.tsx
  • src/frontend/apps/calendars/src/services/dav/CalDavService.ts -- extend with resource PROPFIND/PROPPATCH methods
  • Updates to event modal for resource attendee support

The frontend reads/writes resource metadata via CalDAV (PROPFIND/PROPPATCH), the same way it already manages calendar properties. The Django REST API is only used for provisioning (create/delete).

Phase 5: Advanced Features

Goal: Polish and power-user features.

  1. Approval workflow: Notification system for manual mode resources
  2. Booking policies: Max duration, booking window, recurring limits
  3. Resource groups: Group rooms by building/floor for easier browsing
  4. Capacity warnings: Warn when event attendee count exceeds room capacity
  5. Resource calendar overlay: Show resource bookings in the main calendar view
  6. Reporting: Usage statistics, popular times, underutilized resources
  7. VAVAILABILITY editor: UI for configuring resource operating hours
  8. Bulk resource import: CSV/JSON import for provisioning many resources

Database Schema Changes

SabreDAV: Principals Table

The principals table includes two extra columns beyond the SabreDAV defaults (defined in src/caldav/sql/pgsql.principals.sql):

CREATE TABLE principals (
    id SERIAL NOT NULL,
    uri VARCHAR(200) NOT NULL,
    email VARCHAR(80),
    displayname VARCHAR(80),
    calendar_user_type VARCHAR(20) DEFAULT 'INDIVIDUAL',  -- INDIVIDUAL, ROOM, RESOURCE
    org_id VARCHAR(200),                                   -- organization scoping
    PRIMARY KEY (id),
    UNIQUE (uri)
);

CREATE INDEX idx_principals_org_id ON principals (org_id) WHERE org_id IS NOT NULL;
CREATE INDEX idx_principals_cutype ON principals (calendar_user_type)
    WHERE calendar_user_type IN ('ROOM', 'RESOURCE');

The calendar_user_type column is exposed as the standard {urn:ietf:params:xml:ns:caldav}calendar-user-type DAV property via AutoCreatePrincipalBackend.$fieldMap. The org_id column is used internally for org-scoped filtering and is not exposed as a DAV property.

Resource metadata (capacity, location, equipment, etc.) is stored in SabreDAV's existing propertystorage table via PROPPATCH -- no additional SabreDAV schema changes needed.

Django: No New Tables

No Django models are needed for resources. Access control uses CalDAV sharing (stored in SabreDAV's calendarinstances table). Resource metadata uses SabreDAV's propertystorage table. Both are managed through CalDAV protocol, not direct database access.


API Design

Resource metadata is read/written via CalDAV (PROPFIND/PROPPATCH). The Django REST API only handles provisioning and deletion.

Django REST API (Provisioning)

POST   /api/v1.0/resources/                         # Create resource (provision principal + calendar)
DELETE /api/v1.0/resources/{resource-id}/                   # Delete resource (cleanup principal + calendar)

Both POST and DELETE require the can_admin entitlement. Access control (sharing) is managed via CalDAV CS:share on the resource's calendar -- no Django access endpoints needed.

Create Resource Request

POST /api/v1.0/resources/
{
  "name": "Room 101 - Large Conference",
  "resource_type": "ROOM"
}

Django checks the user's can_admin entitlement, provisions the CalDAV principal and calendar, then the frontend sets additional metadata via PROPPATCH.

CalDAV API (Metadata and Discovery)

All resource metadata is managed via standard CalDAV protocol:

Operation Method URL
List all resources PROPFIND /api/v1.0/caldav/principals/resources/
Get resource properties PROPFIND /api/v1.0/caldav/principals/resources/{resource-id}/
Update resource properties PROPPATCH /api/v1.0/caldav/principals/resources/{resource-id}/
Get resource calendar PROPFIND /api/v1.0/caldav/calendars/resources/{resource-id}/default/
Query free/busy REPORT /api/v1.0/caldav/calendars/resources/{resource-id}/default/

The frontend fetches all resource principals with their properties in a single PROPFIND request and filters/sorts client-side. This is the same pattern used for fetching calendars today.


SabreDAV Plugin Design

CUTYPE support uses SabreDAV's built-in $fieldMap extension point (no plugin needed). Auto-scheduling requires a custom plugin because SabreDAV has no built-in resource auto-scheduling, and no reusable third-party implementation exists (see What SabreDAV Provides).

ResourceAutoSchedulePlugin

class ResourceAutoSchedulePlugin extends DAV\ServerPlugin
{
    function getPluginName() { return 'resource-auto-schedule'; }

    function initialize(DAV\Server $server)
    {
        $server->on('schedule', [$this, 'autoSchedule'], 120);
        // Priority 120: runs after Schedule\Plugin (110)
    }

    function autoSchedule(ITip\Message $message)
    {
        // Only handle messages TO resource principals
        if (!$this->isResourcePrincipal($message->recipient)) {
            return;
        }

        // Read auto-schedule-mode from propertystorage table
        $mode = $this->getAutoScheduleMode($message->recipient);

        switch ($mode) {
            case 'accept_always':
                $this->acceptInvitation($message);
                break;
            case 'decline_always':
                $this->declineInvitation($message);
                break;
            case 'automatic':
                if ($this->hasConflict($message)) {
                    $this->declineInvitation($message);
                } else {
                    $this->acceptInvitation($message);
                }
                break;
            case 'manual':
                // Do nothing; leave PARTSTAT=NEEDS-ACTION
                // Managers see pending requests on the resource calendar
                break;
        }
    }
}

Integration with Existing Plugins

The plugin runs in this order:

  1. CalendarSanitizerPlugin (priority 85) -- strips binaries, truncates
  2. AttendeeNormalizerPlugin (priority 90) -- normalizes emails
  3. CalDAV\Schedule\Plugin (priority 110) -- delivers iTIP messages
  4. ResourceAutoSchedulePlugin (priority 120) -- auto-accept/decline
  5. HttpCallbackIMipPlugin -- notifies Django (for email delivery)

Frontend Components

Data Flow

The frontend reads/writes resource metadata via CalDAV, just like it does for calendars:

CalDavService.fetchResourcePrincipals()   → PROPFIND /principals/resources/
CalDavService.getResourceProperties(id)    → PROPFIND /principals/resources/{resource-id}/
CalDavService.updateResourceProperties()   → PROPPATCH /principals/resources/{resource-id}/
CalDavService.fetchResourceEvents()        → REPORT on resource calendar
CalDavService.queryResourceFreeBusy()      → free-busy-query REPORT

The Django REST API is only called for provisioning (create/delete). Everything else goes through CalDAV.

Resource Directory (/resources)

A browsable directory of all resources with:

  • Filter sidebar: Type (room/resource), capacity range, tags, location, availability -- all filtering is client-side after a single PROPFIND
  • List/grid view: Cards showing name, location, capacity, availability status
  • Search: Real-time search across name, description, location
  • Quick book: Click to start creating an event with the resource

Resource Picker (Event Modal)

When creating/editing an event:

  • "Add Room" / "Add Resource" button in the attendees section
  • Opens a filtered dropdown/modal showing available resources
  • Shows real-time availability for the selected event time
  • Displays capacity and key metadata inline
  • Selected resources appear in the attendees list with a room/resource icon

Resource Timeline

A horizontal timeline showing:

  • One row per resource
  • Colored blocks for existing bookings
  • Grey blocks for unavailable hours
  • Ability to click an empty slot to book

Resource Admin Panel

For resource administrators:

  • Create/edit resource metadata (PROPPATCH to CalDAV)
  • Configure auto-schedule mode and booking policies (PROPPATCH)
  • Manage access via CalDAV sharing (CS:share) -- same UI as calendar sharing
  • View booking history and usage stats
  • Override booking decisions (accept/decline pending requests)

Migration and Deployment

Rolling Deployment Steps

  1. Database migration: Add calendar_user_type to SabreDAV principals (no Django migration needed)
  2. Deploy SabreDAV: Updated principal backend + auto-schedule plugin (no user impact -- new code paths only activate for resource principals)
  3. Deploy Django backend: Resource provisioning + access endpoints (additive, no breaking changes)
  4. Deploy frontend: Resource UI (feature-flagged if needed)

Feature Flag

Consider a RESOURCES_ENABLED feature flag in settings to:

  • Show/hide resource UI in the frontend
  • Enable/disable resource API endpoints
  • Allow gradual rollout

Data Migration

If importing resources from an external system:

  1. Call the Django provisioning endpoint for each resource (creates CalDAV principals)
  2. PROPPATCH to set metadata properties on each resource principal
  3. Import historical bookings as calendar events via ICS import
  4. Verify free/busy accuracy after import