Environment:
- RoomVox 1.4.0
- PHP 8.2+ (any supported version)
Summary
PublicApiController compares the weekday as a string ("mon") against availabilityRules.rules[].days, which is stored as an array of integers (0–6, 0 = Sunday). The comparison can never match on PHP 8, so for any room with availability rules enabled the Public API treats every moment as outside the allowed hours.
The CalDAV/iTIP path is not affected — it does this correctly — so bookings made through a normal calendar client behave as expected. This is why the bug is easy to miss.
Steps to reproduce
- Create a room and enable Restrict booking hours, e.g. the built-in preset Weekdays 08–18 (stores
days: [1,2,3,4,5])
- Create an API token with scope
book
- On a Tuesday at 10:00,
POST /api/v1/rooms/{id}/bookings with a valid title, start and end well inside the window
- Also call
GET /api/v1/rooms/{id}/status
Expected: booking is created (201); status reports free/busy.
Actual: booking is rejected with 422 {"error":"Booking is outside available hours"}; status always reports unavailable.
Rooms without availability rules are unaffected, since the whole block is skipped.
Cause
The data model stores weekdays as integers. From src/views/RoomEditor.vue:513-521:
const weekDays = computed(() => [
{ value: 1, label: t('roomvox', 'Mon') },
...
{ value: 0, label: t('roomvox', 'Sun') },
])
and the presets at src/views/RoomEditor.vue:221 pass applyPreset([1,2,3,4,5], '08:00', '18:00').
SchedulingPlugin::bookingFitsRule() reads this correctly — lib/Dav/SchedulingPlugin.php:817:
// Get day of week (0=Sunday, 6=Saturday) matching our data model
$startDay = (int)$start->format('w');
...
return in_array($startDay, $allowedDays, true)
PublicApiController instead formats the day as a lowercase abbreviation and compares it loosely against those integers, in three places:
1. roomStatus() — lib/Controller/PublicApiController.php:76
$dayOfWeek = strtolower($now->format('D')); // mon, tue, etc.
...
if (in_array($dayOfWeek, $rule['days'] ?? []) && ...
2. roomAvailability() — lib/Controller/PublicApiController.php:190
$dayOfWeek = strtolower($rangeStart->format('D'));
foreach ($room['availabilityRules']['rules'] ?? [] as $rule) {
if (in_array($dayOfWeek, $rule['days'] ?? [])) {
3. createBooking() — lib/Controller/PublicApiController.php:397
$dayOfWeek = strtolower($startDt->format('D'));
...
if (in_array($dayOfWeek, $rule['days'] ?? []) && ...
return new JSONResponse(['error' => 'Booking is outside available hours'], 422);
Since PHP 8 changed string↔int comparison, "mon" == 1 is false (under PHP 7 semantics "mon" == 0 would have been true, which would have made Sunday spuriously match). Confirmed on PHP 8.5.4:
date: 2026-09-07 (Monday), rule days: [1,2,3,4,5]
PublicApiController: in_array("mon", [1,2,3,4,5]) = false
SchedulingPlugin: in_array(1, [1,2,3,4,5], true) = true
Impact
For rooms with availability rules enabled:
| Endpoint |
Effect |
POST /api/v1/rooms/{id}/bookings |
Always 422 — booking via the API is impossible |
GET /api/v1/rooms/{id}/status |
Always reports unavailable |
GET /api/v1/rooms/{id}/availability |
Falls through to the 00:00–23:59 default, so the room's real opening hours are never reflected |
This makes the Public API unusable for its documented purpose (displays, kiosks, digital signage) on exactly those rooms that have opening hours configured.
Suggested fix
Use (int)$dt->format('w') and strict in_array(..., true) in all three places, matching SchedulingPlugin. Given the logic is now duplicated in four locations with two different data interpretations, extracting a single shared helper (e.g. on RoomService or a small AvailabilityRules value object) and having both SchedulingPlugin and PublicApiController call it would prevent this class of drift.
Worth adding a regression test that feeds the same room + timestamp through both paths and asserts they agree.
Related observations
While verifying the above, two adjacent inconsistencies surfaced. Happy to split these into separate issues if preferred:
-
Availability rules are not applied per occurrence. docs/features/availability-rules.md:51 states "A weekly meeting fails if any week falls outside the availability rules", but isWithinAvailability() is called once, on the master event's DTSTART/DTEND (lib/Dav/SchedulingPlugin.php:208). The booking-horizon check does handle recurrence (:720-781), and hasConflict() expands occurrences (lib/Service/CalDAVService.php:1000-1021) — only the availability rules do not. Either the docs or the code should be corrected.
-
BookingApiController enforces no availability rules or horizon at all — it calls hasConflict() only (:100, :194, :233). Bookings created through the admin UI therefore bypass configured opening hours. This may well be intentional (admins may override), but it is not documented anywhere.
Environment:
Summary
PublicApiControllercompares the weekday as a string ("mon") againstavailabilityRules.rules[].days, which is stored as an array of integers (0–6, 0 = Sunday). The comparison can never match on PHP 8, so for any room with availability rules enabled the Public API treats every moment as outside the allowed hours.The CalDAV/iTIP path is not affected — it does this correctly — so bookings made through a normal calendar client behave as expected. This is why the bug is easy to miss.
Steps to reproduce
days: [1,2,3,4,5])bookPOST /api/v1/rooms/{id}/bookingswith a validtitle,startandendwell inside the windowGET /api/v1/rooms/{id}/statusExpected: booking is created (201); status reports
free/busy.Actual: booking is rejected with
422 {"error":"Booking is outside available hours"}; status always reportsunavailable.Rooms without availability rules are unaffected, since the whole block is skipped.
Cause
The data model stores weekdays as integers. From
src/views/RoomEditor.vue:513-521:and the presets at
src/views/RoomEditor.vue:221passapplyPreset([1,2,3,4,5], '08:00', '18:00').SchedulingPlugin::bookingFitsRule()reads this correctly —lib/Dav/SchedulingPlugin.php:817:PublicApiControllerinstead formats the day as a lowercase abbreviation and compares it loosely against those integers, in three places:1.
roomStatus()—lib/Controller/PublicApiController.php:762.
roomAvailability()—lib/Controller/PublicApiController.php:1903.
createBooking()—lib/Controller/PublicApiController.php:397Since PHP 8 changed string↔int comparison,
"mon" == 1isfalse(under PHP 7 semantics"mon" == 0would have beentrue, which would have made Sunday spuriously match). Confirmed on PHP 8.5.4:Impact
For rooms with availability rules enabled:
POST /api/v1/rooms/{id}/bookingsGET /api/v1/rooms/{id}/statusunavailableGET /api/v1/rooms/{id}/availability00:00–23:59default, so the room's real opening hours are never reflectedThis makes the Public API unusable for its documented purpose (displays, kiosks, digital signage) on exactly those rooms that have opening hours configured.
Suggested fix
Use
(int)$dt->format('w')and strictin_array(..., true)in all three places, matchingSchedulingPlugin. Given the logic is now duplicated in four locations with two different data interpretations, extracting a single shared helper (e.g. onRoomServiceor a smallAvailabilityRulesvalue object) and having bothSchedulingPluginandPublicApiControllercall it would prevent this class of drift.Worth adding a regression test that feeds the same room + timestamp through both paths and asserts they agree.
Related observations
While verifying the above, two adjacent inconsistencies surfaced. Happy to split these into separate issues if preferred:
Availability rules are not applied per occurrence.
docs/features/availability-rules.md:51states "A weekly meeting fails if any week falls outside the availability rules", butisWithinAvailability()is called once, on the master event'sDTSTART/DTEND(lib/Dav/SchedulingPlugin.php:208). The booking-horizon check does handle recurrence (:720-781), andhasConflict()expands occurrences (lib/Service/CalDAVService.php:1000-1021) — only the availability rules do not. Either the docs or the code should be corrected.BookingApiControllerenforces no availability rules or horizon at all — it callshasConflict()only (:100,:194,:233). Bookings created through the admin UI therefore bypass configured opening hours. This may well be intentional (admins may override), but it is not documented anywhere.