returnsNativeTypes())
{
return;
}
$columns = Columns::getInstance(_tbl($table_short_name));
foreach ($columns as $column)
{
if (isset($row[$column->name]))
{
switch ($column->getNature())
{
case Column::NATURE_BOOLEAN:
// Cast those fields which are booleans into booleans
$row[$column->name] = (bool) $row[$column->name];
break;
case Column::NATURE_INTEGER:
// MySQL will return strings for integer fields, so cast them into ints
$row[$column->name] = (int) $row[$column->name];
break;
case Column::NATURE_REAL:
// MySQL will return strings for real fields, so cast them into floats
$row[$column->name] = (float) $row[$column->name];
break;
default:
break;
}
}
}
}
// Pack the various status fields into a single bitmap
function pack_status(array &$data)
{
$statuses = array('awaiting_approval' => STATUS_AWAITING_APPROVAL,
'private' => STATUS_PRIVATE,
'tentative' => STATUS_TENTATIVE);
$data['status'] = 0;
foreach ($statuses as $key => $value)
{
if (!empty($data[$key]))
{
$data['status'] |= $value;
}
unset($data[$key]);
}
}
// Translates the status flags into a set of booleans
function unpack_status(array &$data)
{
if (isset($data))
{
$data['awaiting_approval'] = (bool) ($data['status'] & STATUS_AWAITING_APPROVAL);
$data['private'] = (bool) ($data['status'] & STATUS_PRIVATE);
$data['tentative'] = (bool) ($data['status'] & STATUS_TENTATIVE);
unset($data['status']);
}
}
/**
* Check whether the proposed booking would conflict with any existing bookings.
*
* @param array $booking The booking in question; an associative array.
* @param int|null $ignore_entry_id If set, ignore this entry ID.
* @param int|null $ignore_repeat_id If set, ignore all entries with this repeat ID.
*
* @return string[] An array of conflicts, empty if there are none.
*/
function mrbsCheckFree(array $booking, ?int $ignore_entry_id=null, ?int $ignore_repeat_id=null) : array
{
global $enable_periods, $is_private_field;
$room_id = $booking['room_id'];
$area_id = get_area($room_id);
if (empty($area_id))
{
// This shouldn't happen. If it does, then issue a warning, but return an empty array as there can
// be no conflicts.
trigger_error("The room with id '$room_id' does not exist.", E_USER_WARNING);
return [];
}
get_area_settings($area_id);
// Select any meetings which overlap for this room:
$sql = "SELECT E.id, name, start_time, end_time, create_by, status, room_name
FROM " . _tbl('entry') . " E, " . _tbl('room') . " R
WHERE E.room_id=R.id
AND start_time < ?
AND end_time > ?
AND E.room_id = ?";
$sql_params = array($booking['end_time'],
$booking['start_time'],
$room_id);
if (isset($ignore_entry_id))
{
$sql .= " AND E.id <> ?";
$sql_params[] = $ignore_entry_id;
}
if (isset($ignore_repeat_id))
{
$sql .= " AND (repeat_id IS NULL OR repeat_id <> ?)";
$sql_params[] = $ignore_repeat_id;
}
$sql .= " ORDER BY start_time";
$res = db()->query($sql, $sql_params);
if ($res->count() == 0)
{
return array();
}
// Build a listing all the conflicts:
$err = array();
while (false !== ($row = $res->next_row_keyed()))
{
row_cast_columns($row, 'entry');
unpack_status($row);
$existing_entry_interval = new EntryInterval($row['start_time'], $row['end_time'], $enable_periods);
$existing_start_date = new DateTime();
$existing_start_date->setTimestamp($row['start_time']);
$timestr = (string) $existing_entry_interval;
if (is_private_event($row['private']) &&
$is_private_field['entry.name'] &&
!getWritable($row['create_by'], $room_id))
{
$row['name'] = get_vocab("unavailable");
}
// enclose the viewday etc. links in a span to make it easier for JavaScript to strip them out
$vars = [
'page_date' => $existing_start_date->getISODate(),
'area' => $area_id,
'room' => $room_id
];
$query = http_build_query($vars, '', '&');
$err[] = '' .
escape_html($row['name']) . '' .
' (' . escape_html($timestr) . ', ' . escape_html($row['room_name']) . ') ' .
'(' .
'' . get_vocab('viewday') . ' | ' .
'' . get_vocab('viewweek') . ' | ' .
'' . get_vocab('viewmonth') . '' .
')';
}
return $err;
}
/**
* Check whether the proposed booking $booking would exceed the maximum number of simultaneous bookings
* for the user.
*
* @param array $booking
* @param int|null $ignore_entry_id If set then ignore the entry with this ID.
* @param int|null $ignore_repeat_id If set then ignore entries with this repeat ID.
* @return string|null An error message, or NULL if the proposed booking is OK.
*/
function check_simultaneous(array $booking, ?int $ignore_entry_id, ?int $ignore_repeat_id) : ?string
{
// TODO: Maybe we should ignore bookings in rooms where the user is a booking admin??
global $max_simultaneous_bookings, $simultaneous_ignore_other_areas;
static $existing = [];
static $proposed = [];
static $area_id = null;
static $check_complete = false;
// Check to see whether we are searching globally across a mixture of times and periods.
// This will lead to false clashes because (a) MRBS stores periods as one-minute bookings
// starting at 12:00 and (b) MRBS does not know what times periods correspond to.
// TODO: Take account of the fact that it is now possible to assign times to periods.
if (!$simultaneous_ignore_other_areas && !$check_complete)
{
if (periods_somewhere(true) && times_somewhere(true))
{
$message = 'It does not make sense to check the whole system for simultaneous bookings ' .
'when there are some areas using periods and others using times. You should ' .
'either change the value of the config setting $simultaneous_ignore_other_areas ' .
'or change your area settings.';
trigger_error($message, E_USER_WARNING);
}
$check_complete = true;
}
// Get the area id if we need it (it will be the same for all bookings, so
// save it in a static variable).
if ($simultaneous_ignore_other_areas && !isset($area_id))
{
$room_details = get_room_details($booking['room_id']);
if (!isset($room_details['area_id']))
{
throw new Exception("Cannot get the area id for the room with id '" . $booking['room_id'] . "'");
}
$area_id = $room_details['area_id'];
}
// Get the existing bookings for this user and interval, if we don't already have them
if (!isset($existing[$booking['start_time']]))
{
// If $simultaneous_ignore_other_areas is false then $area_id will be null, which is
// what we want.
$existing[$booking['start_time']] = count(get_entry_times_by_user(
$booking['create_by'],
$booking['start_time'],
$booking['end_time'],
$area_id,
false,
$ignore_entry_id,
$ignore_repeat_id)
);
}
// Increment the proposed bookings
if (!isset($proposed[$booking['start_time']]))
{
$proposed[$booking['start_time']] = 1;
}
else
{
$proposed[$booking['start_time']]++;
}
// Check whether this would break the rules
if (($existing[$booking['start_time']] + $proposed[$booking['start_time']]) > $max_simultaneous_bookings)
{
// The number is sent as a string to allow words instead of numbers in the future, eg "two" instead of 2
return get_vocab('no_simultaneous_bookings', strval($max_simultaneous_bookings));
}
return null;
}
// Checks whether the proposed booking $booking would exceed the maximum number/length of
// bookings in the interval of type $interval_type (can be 'day', 'week', 'month', 'year' or
// 'future'). If $only_area is set then only the bookings in the same area are considered.
// If $check_seconds is set then the total length of bookings is checked, otherwise the number.
// Returns NULL if OK, otherwise an error string.
function check_interval(string $interval_type, array $booking, ?int $ignore_entry_id, ?int $ignore_repeat_id, bool $only_area=false, bool $check_seconds=false) : ?string
{
global $max_per_interval_global, $max_per_interval_area;
global $max_secs_per_interval_global, $max_secs_per_interval_area;
global $exempt_types;
// No need to check anything if we're checking the total length of bookings and this booking type is exempt.
if ($check_seconds)
{
if (!empty($exempt_types['max_length']) && in_array($booking['type'], $exempt_types['max_length']))
{
return null;
}
}
// Or if we're checking the total number of bookings and this booking type is exempt.
else
{
if (!empty($exempt_types['max_number']) && in_array($booking['type'], $exempt_types['max_number']))
{
return null;
}
}
// Get the area id. We only need to do this once as all bookings will be
// in the same area.
static $area_id = null;
if (!isset($area_id))
{
$area_id = get_area($booking['room_id']);
}
// Get the location for indexing the $existing and $proposed arrays. If it's a
// global check then $location = 0, otherwise use the area_id.
$location = ($only_area) ? $area_id : 0;
// Get the check type for indexing the $existing and $proposed arrays
$check_type = ($check_seconds) ? 'length' : 'number';
// Set up arrays recording the number of existing and proposed bookings for the interval,
// indexed by the interval type and the Unix time at the start of that interval. These
// are static variables because we test all the proposed bookings, which could be for
// multiple rooms and/or for repeat bookings, before making the booking.
static $existing = array();
static $proposed = array();
// Loop through all the intervals in the proposed booking, counting how many bookings
// already exist for that interval, and incrementing the number of proposed bookings
// by one
$start_date = getdate($booking['start_time']);
$i = 1;
switch ($interval_type)
{
case 'day':
$interval_start = mktime(0, 0, 0, $start_date['mon'], $start_date['mday'], $start_date['year']);
break;
case 'week':
$skipback = day_of_MRBS_week($booking['start_time']);
$interval_start = mktime(0, 0, 0, $start_date['mon'], $start_date['mday'] - $skipback, $start_date['year']);
break;
case 'month':
$interval_start = mktime(0, 0, 0, $start_date['mon'], 1, $start_date['year']);
break;
case 'year':
$interval_start = mktime(0, 0, 0, 1, 1, $start_date['year']);
break;
case 'future':
$interval_start = time();
break;
}
while ($interval_start < $booking['end_time'])
{
switch ($interval_type)
{
case 'day':
$interval_end = mktime(0, 0, 0, $start_date['mon'], $start_date['mday'] + $i, $start_date['year']);
break;
case 'week':
$interval_end = mktime(0, 0, 0, $start_date['mon'], $start_date['mday'] + ($i * DAYS_PER_WEEK) - $skipback, $start_date['year']);
break;
case 'month':
$interval_end = mktime(0, 0, 0, $start_date['mon'] + $i, 1, $start_date['year']);
break;
case 'year':
$interval_end = mktime(0, 0, 0, 1, 1, $start_date['year'] + $i);
break;
case 'future':
$interval_end = null; // Effectively infinity. We can't use PHP_INT_MAX because this will break some databases.
break;
}
if (!isset($existing[$location][$check_type][$interval_type][$interval_start]))
{
$existing[$location][$check_type][$interval_type][$interval_start] = 0;
$proposed[$location][$check_type][$interval_type][$interval_start] = 0;
if ($check_seconds)
{
$ignore_types = $exempt_types['max_length'] ?? [];
}
else
{
$ignore_types = $exempt_types['max_number'] ?? [];
}
$rows = get_entry_times_by_user(
$booking['create_by'],
$interval_start,
$interval_end,
($only_area) ? $area_id : null,
$check_seconds,
$ignore_entry_id,
$ignore_repeat_id,
$ignore_types
);
foreach ($rows as $row)
{
// If we're checking for total time we want the length of the existing booking that falls
// inside the interval. Otherwise it's just one more booking.
if ($check_seconds)
{
$seconds = (isset($interval_end)) ? min($row['end_time'], $interval_end) : $row['end_time'];
$seconds = $seconds - max($row['start_time'], $interval_start);
$existing[$location][$check_type][$interval_type][$interval_start] += $seconds;
}
else
{
$existing[$location][$check_type][$interval_type][$interval_start]++;
}
}
}
// If we're checking for total time we want the length of the proposed booking that falls
// inside the interval. Otherwise it's just one more booking.
if ($check_seconds)
{
$seconds = (isset($interval_end)) ? min($booking['end_time'], $interval_end) : $booking['end_time'];
$seconds = $seconds - max($booking['start_time'], $interval_start);
$proposed[$location][$check_type][$interval_type][$interval_start] += $seconds;
}
else
{
$proposed[$location][$check_type][$interval_type][$interval_start]++;
}
// Check to see if the maximum number/length of bookings would be exceeded
if ($check_seconds)
{
$max_allowed = ($only_area) ? $max_secs_per_interval_area[$interval_type] : $max_secs_per_interval_global[$interval_type];
if (($existing[$location][$check_type][$interval_type][$interval_start] +
$proposed[$location][$check_type][$interval_type][$interval_start]) >
$max_allowed)
{
if ($only_area)
{
$max = $max_secs_per_interval_area[$interval_type];
toTimeString($max, $units);
return get_vocab("max_secs_per_{$interval_type}_area", $max, $units);
}
else
{
$max = $max_secs_per_interval_global[$interval_type];
toTimeString($max, $units);
return get_vocab("max_secs_per_{$interval_type}_global", $max, $units);
}
}
}
else
{
$max_allowed = ($only_area) ? $max_per_interval_area[$interval_type] : $max_per_interval_global[$interval_type];
if (($existing[$location][$check_type][$interval_type][$interval_start] +
$proposed[$location][$check_type][$interval_type][$interval_start]) >
$max_allowed)
{
if ($only_area)
{
return get_vocab("max_per_{$interval_type}_area") . " $max_per_interval_area[$interval_type]";
}
else
{
return get_vocab("max_per_{$interval_type}_global") . " $max_per_interval_global[$interval_type]";
}
}
}
if (isset($interval_end))
{
$interval_start = $interval_end;
$i++;
}
else
{
// $interval_end won't be set if we're looking at all bookings in the future, in
// which case the interval end is effectively infinity and we should stop.
break;
}
}
return null;
}
// Adjusts a book ahead time when using periods. Because we don't
// know when periods are in the day we adjust the book ahead time
// down to the nearest whole day.
function adjust_ahead_seconds(int $seconds) : int
{
global $enable_periods;
if ($enable_periods)
{
// Round down to the nearest whole day
$seconds -= $seconds % SECONDS_PER_DAY;
}
return $seconds;
}
/**
* Check to see if a proposed booking conforms to the booking policies in force. Can be used for both creating and
* deleting an entry.
*
* @param array $booking The booking in question; an associative array
* @param int|null $ignore_entry_id If set, then ignore the entry with this ID.
* @param int|null $ignore_repeat_id If set, then ignore entries with this repeat ID.
* @param bool $delete If TRUE, then we're checking whether a proposed booking can be deleted, otherwise whether it can
* be created.
*
* @return array{notices: array, errors: array} An array of human-readable errors, indexed by 'notices'
* or 'errors'. If there are no policy violations the elements of the arrays will be empty.
*/
function mrbsCheckPolicy(array $booking, ?int $ignore_entry_id=null, ?int $ignore_repeat_id=null, bool $delete=false) : array
{
global $periods, $enable_periods;
global $min_create_ahead_enabled, $min_create_ahead_secs;
global $max_create_ahead_enabled, $max_create_ahead_secs;
global $min_delete_ahead_enabled, $min_delete_ahead_secs;
global $max_delete_ahead_enabled, $max_delete_ahead_secs;
global $min_booking_date_enabled, $min_booking_date, $max_booking_date_enabled, $max_booking_date;
global $max_duration_enabled, $max_duration_secs, $max_duration_periods;
global $max_per_interval_global_enabled, $max_per_interval_area_enabled;
global $max_secs_per_interval_global_enabled, $max_secs_per_interval_area_enabled;
global $interval_types, $datetime_formats;
global $approved_bookings_cannot_be_changed, $approval_enabled;
global $prevent_simultaneous_bookings, $prevent_invalid_types, $invalid_types_days;
global $prevent_booking_on_holidays, $prevent_booking_on_weekends;
global $periods_booking_opens, $times_booking_opens, $measure_max_to_start_time, $timezone;
global $exempt_types;
global $auth;
$result = array('notices' => array(),
'errors' => array());
$violations = array();
// If the user is a booking admin for this room then we still check for policy
// violations, but they are for information only and are classed as 'notices'
$violation_status = (is_book_admin($booking['room_id']) && !$auth['admin_must_obey_policies']) ? 'notices' : 'errors';
$now = time();
if ($enable_periods)
{
// And now add on the difference between the internal start of periods (12:00)
// and the booking day start for periods
$now += strtotime('12:00') - strtotime($periods_booking_opens);
}
// Check deletion policies
if ($delete)
{
if ($approval_enabled && $approved_bookings_cannot_be_changed && !$booking['awaiting_approval'])
{
$violations[] = get_vocab('cannot_change_approved_bookings');
}
if ($min_delete_ahead_enabled)
{
$min_delete_ahead = adjust_ahead_seconds($min_delete_ahead_secs);
if ($booking['start_time'] - $now < $min_delete_ahead + cross_dst($now, $booking['start_time']))
{
toTimeString($min_delete_ahead, $units);
$violations[] = get_vocab('min_delete_time_before', $min_delete_ahead, $units);
}
}
if ($max_delete_ahead_enabled)
{
$max_delete_ahead = adjust_ahead_seconds($max_delete_ahead_secs);
$edge = ($measure_max_to_start_time) ? $booking['start_time'] : $booking['end_time'];
if ($edge - $now > $max_delete_ahead + cross_dst($now, $edge))
{
toTimeString($max_delete_ahead, $units);
$tag = ($measure_max_to_start_time) ? 'max_delete_time_before_start' : 'max_delete_time_before';
$violations[] = get_vocab($tag, $max_delete_ahead, $units);
}
}
}
// Check creation policies
else
{
$entryInterval = new EntryInterval($booking['start_time'], $booking['end_time'], $enable_periods);
// Check whether the user is trying to book on a holiday
if ($prevent_booking_on_holidays)
{
if (false !== ($date = $entryInterval->overlapsHoliday()))
{
$violations[] = get_vocab('no_bookings_on_holidays', datetime_format($datetime_formats['date_holiday'], $date->getTimestamp()));
}
}
// Check whether the user is trying to book on a weekend
if ($prevent_booking_on_weekends)
{
if (false !== ($date = $entryInterval->overlapsWeekend()))
{
$violations[] = get_vocab('no_bookings_on_weekends', datetime_format($datetime_formats['date_holiday'], $date->getTimestamp()));
}
}
// Check whether the user is trying to book multiple rooms simultaneously
if ($prevent_simultaneous_bookings)
{
$tmp = check_simultaneous($booking, $ignore_entry_id, $ignore_repeat_id);
if (isset($tmp))
{
$violations[] = $tmp;
}
}
// Check whether this booking type is allowed ...
if ($prevent_invalid_types)
{
// ... in this room
$room_details = get_room_details($booking['room_id']);
if (isset($room_details['invalid_types']) &&
in_array($booking['type'], $room_details['invalid_types']))
{
// TODO: is this the best place for the escape_html()? Should it go closer to the output?
$violations[] = get_vocab('type_not_allowed', get_type_vocab($booking['type']), escape_html($room_details['room_name']));
}
// ... on this day
if (!empty($invalid_types_days[$booking['type']]))
{
$overlap_date = $entryInterval->overlapsDays($invalid_types_days[$booking['type']]);
if ($overlap_date !== false)
{
$overlap_day = datetime_format(['pattern' => 'cccc'], $overlap_date->getTimestamp());
$violations[] = get_vocab('type_not_allowed_day', get_type_vocab($booking['type']), $overlap_day);
}
}
}
if ($min_create_ahead_enabled)
{
$min_create_ahead = adjust_ahead_seconds($min_create_ahead_secs);
if ($booking['start_time'] - $now < $min_create_ahead + cross_dst($now, $booking['start_time']))
{
toTimeString($min_create_ahead, $units);
$violations[] = get_vocab('min_create_time_before', $min_create_ahead, $units);
}
}
if ($max_create_ahead_enabled && !(isset($exempt_types['max_create_ahead']) && in_array($booking['type'], $exempt_types['max_create_ahead'])))
{
$max_create_ahead = adjust_ahead_seconds($max_create_ahead_secs);
$edge = ($measure_max_to_start_time) ? $booking['start_time'] : $booking['end_time'];
// Adjust the edge if we're opening booking at a fixed time and the book ahead maximum is a whole number of days
if (!$enable_periods && isset($times_booking_opens) && ($max_create_ahead % SECONDS_PER_DAY === 0))
{
$edge = timestamp_set_time($edge, $times_booking_opens, $timezone);
$booking_opens_message = get_vocab('booking_opens_at', datetime_format($datetime_formats['time'], $edge));
}
// Test whether the booking is being made too early, compensating for DST transitions.
if ($edge - $now > $max_create_ahead + cross_dst($now, $edge))
{
toTimeString($max_create_ahead, $units);
if (empty($exempt_types['max_create_ahead']))
{
$tag = ($measure_max_to_start_time) ? 'max_create_time_before_start' : 'max_create_time_before';
}
else
{
$tag = ($measure_max_to_start_time) ? 'max_create_time_before_start_type' : 'max_create_time_before_type';
}
$message = get_vocab($tag, $max_create_ahead, $units, get_type_vocab($booking['type']));
if (isset($booking_opens_message))
{
$message .= '. ' . $booking_opens_message . '.';
}
$violations[] = $message;
}
}
}
// Check min_booking_date
if ($min_booking_date_enabled && !$delete)
{
// Use DateTimeImmutable because we will need the original date for the error message
$date_immutable = DateTimeImmutable::createFromFormat($format=DateTime::ISO8601_DATE, $min_booking_date);
// Check that the date is valid (createFromFormat will allow, for example, months > 12)
if (($date_immutable === false) || ($date_immutable->format($format) !== $min_booking_date))
{
throw new \Exception('MRBS config error: invalid $min_booking_date ' . "'$min_booking_date'");
}
if ($booking['start_time'] < $date_immutable->setTime(0, 0)->getTimestamp())
{
$violations[] = get_vocab(
'earliest_booking_date',
datetime_format($datetime_formats['date'], $date_immutable->getTimestamp())
);
}
}
// Check max_booking_date
if ($max_booking_date_enabled && !$delete)
{
// Use DateTimeImmutable because we will need the original date for the error message
$date_immutable = DateTimeImmutable::createFromFormat($format=DateTime::ISO8601_DATE, $max_booking_date);
// Check that the date is valid (createFromFormat will allow, for example, months > 12)
if (($date_immutable === false) || ($date_immutable->format($format) !== $max_booking_date))
{
throw new \Exception('MRBS config error: invalid $max_booking_date ' . "'$max_booking_date'");
}
if ($booking['end_time'] > $date_immutable->setTime(0, 0)->modify('+1 day')->getTimestamp())
{
$violations[] = get_vocab(
'latest_booking_date',
datetime_format($datetime_formats['date'], $date_immutable->getTimestamp())
);
}
}
// Check max_duration (but not if we're deleting a booking)
if ($max_duration_enabled && !$delete)
{
if ($enable_periods)
{
if (empty($exempt_types['max_duration_periods']) || !in_array($booking['type'], $exempt_types['max_duration_periods']))
{
// Instead of calculating the difference between the start and end times and
// comparing that with the maximum duration, we add the maximum duration to the
// start time and compare that with the actual end time
$start = getdate($booking['start_time']);
$start['minutes'] += $max_duration_periods;
$n_periods = count($periods);
// If we've gone over into another day, adjust the minutes and days accordingly
while ($start['minutes'] >= $n_periods)
{
$start['minutes'] -= $n_periods;
$start['mday']++;
}
$max_endtime = mktime($start['hours'], $start['minutes'], $start['seconds'],
$start['mon'], $start['mday'], $start['year']);
if ($booking['end_time'] > $max_endtime)
{
$tag = (empty($exempt_types['max_duration_periods'])) ? 'max_booking_duration' : 'max_booking_duration_type';
$units = ($max_duration_periods > 1) ? get_vocab("periods") : get_vocab("period_lc");
$violations[] = get_vocab($tag, $max_duration_periods, $units, get_type_vocab($booking['type']));
}
}
}
elseif ((empty($exempt_types['max_duration_secs']) || !in_array($booking['type'], $exempt_types['max_duration_secs'])) &&
($booking['end_time'] - $booking['start_time'] > $max_duration_secs))
{
$tag = (empty($exempt_types['max_duration_secs'])) ? 'max_booking_duration' : 'max_booking_duration_type';
$max_duration = $max_duration_secs;
toTimeString($max_duration, $units);
$violations[] = get_vocab($tag, $max_duration, $units, get_type_vocab($booking['type']));
}
}
// Check max number of bookings allowed per interval for this user for each of
// the interval types, both globally and for the area
foreach ($interval_types as $interval_type)
{
// globally
// number of bookings
if (!empty($max_per_interval_global_enabled[$interval_type]) && !$delete)
{
$tmp = check_interval($interval_type, $booking, $ignore_entry_id, $ignore_repeat_id, false);
if (isset($tmp))
{
$violations[] = $tmp;
}
}
// length of bookings
if (!empty($max_secs_per_interval_global_enabled[$interval_type]) && !$delete)
{
$tmp = check_interval($interval_type, $booking, $ignore_entry_id, $ignore_repeat_id, false, true);
if (isset($tmp))
{
$violations[] = $tmp;
}
}
// for the area
// number of bookings
if (!empty($max_per_interval_area_enabled[$interval_type]) && !$delete)
{
$tmp = check_interval($interval_type, $booking, $ignore_entry_id, $ignore_repeat_id, true);
if (isset($tmp))
{
$violations[] = $tmp;
}
}
// length of bookings
if (!empty($max_secs_per_interval_area_enabled[$interval_type]) && !$delete)
{
$tmp = check_interval($interval_type, $booking, $ignore_entry_id, $ignore_repeat_id, true, true);
if (isset($tmp))
{
$violations[] = $tmp;
}
}
}
$result[$violation_status] = $violations;
return $result;
}
function get_entry_by_id(int $id) : ?array
{
if (!isset($id))
{
return null;
}
$sql = "SELECT *
FROM " . _tbl('entry') . "
WHERE id=?
LIMIT 1";
$res = db()->query($sql, array($id));
if ($res->count() == 0)
{
$result = null;
}
else
{
$result = $res->next_row_keyed();
// Cast the columns to their correct types
row_cast_columns($result, 'entry');
// Unpack the status bits
unpack_status($result);
}
return $result;
}
/**
* Calculate the timestamp for a registration opening/closing boundary.
*
* In periods mode the timestamp will be at `$periods_booking_opens`, because we don't know when in the day periods
* occur. In times mode the timestamp will be at `$times_booking_opens`, if it is set and the advance time is a whole
* number of days.
*
* This function compensates for DST changes, so that, for example, 1 week before occurs at the same time of day as the
* start time, even if there's a DST transition in between.
*
* @param int $start_time The start time of the event (a Unix timestamp)
* @param int $in_advance The number of seconds in advance of the start time that the boundary is to be set to.
*/
function registration_boundary_timestamp(int $start_time, int $in_advance) : int
{
global $enable_periods, $periods_booking_opens, $times_booking_opens;
// If we're using periods set the time back to the booking opening time
if ($enable_periods)
{
$start_time = timestamp_set_time($start_time, $periods_booking_opens);
}
// And if we're using times, and the booking opening time is set and the advance time is a whole number of days,
// then adjust the time accordingly.
elseif (isset($times_booking_opens) && ($in_advance % SECONDS_PER_DAY === 0))
{
$start_time = timestamp_set_time($start_time, $times_booking_opens);
}
$boundary_time = $start_time - $in_advance;
return $boundary_time - cross_dst($boundary_time, $start_time);
}
/**
* Returns the Unix timestamp for registration opening.
*/
function registration_opens_timestamp(array $entry) : int
{
return registration_boundary_timestamp($entry['start_time'], $entry['registration_opens']);
}
/**
* Returns the Unix timestamp for registration closing.
*/
function registration_closes_timestamp(array $entry) : int
{
return registration_boundary_timestamp($entry['start_time'], $entry['registration_closes']);
}
/**
* Checks whether an entry is open for registration
*/
function entry_registration_is_open(array $entry) : bool
{
return (!$entry['registration_opens_enabled'] || (time() >= registration_opens_timestamp($entry))) &&
(!$entry['registration_closes_enabled'] || (time() <= registration_closes_timestamp($entry)));
}
/**
* Checks whether it is too late to cancel a registration, which is after the closing
* time for registration, if there is one, or else after the entry start time.
*/
function entry_registration_cancellation_has_closed(array $entry) : bool
{
global $enable_periods;
if (!empty($entry['registration_closes_enabled']))
{
return (time() > registration_closes_timestamp($entry));
}
if ($enable_periods)
{
// Allow registration to be cancelled all the way up to the end of the
// current day, because we don't know when in the day the period starts.
$date = new DateTime();
$date->setTimestamp($entry['start_time']);
$date->setTime(0, 0);
$date->modify('+1 day');
return (time() > $date->getTimestamp());
}
else
{
return (time() > $entry['start_time']);
}
}
/**
* Add a registrant to an entry.
*
* @param array{username: string, create_by: string, registered: int} $registrant The registrant details, with keys
* 'username', 'create_by' and 'registered'
*/
function add_registrant(int $entry_id, array $registrant) : void
{
add_registrants($entry_id, [$registrant]);
}
/**
* Add an array of registrants to an entry.
*
* @param array{array{username: string, create_by: string, registered: int}} $registrant An array of registrant details,
* each one with keys 'username', 'create_by' and 'registered'
*/
function add_registrants(int $entry_id, array $registrants) : void
{
if (empty($registrants))
{
return;
}
$sql_params = array(':entry_id' => $entry_id);
$values = array();
foreach ($registrants as $i => $registrant)
{
$sql_params[":username$i"] = $registrant['username'];
$sql_params[":create_by$i"] = $registrant['create_by'];
$sql_params[":registered$i"] = $registrant['registered'];
$values[] = "(:entry_id, :username$i, :create_by$i, :registered$i)";
}
$sql = "INSERT INTO " . _tbl('participants') . " (entry_id, username, create_by, registered) VALUES " .
implode(', ', $values);
db()->command($sql, $sql_params);
}
// Get all the entries for the area with $area_id in the interval that starts at $start_date
// and ends at $end_date (both DateTime objects). Only get entries for enabled rooms.
// Returns an array of entries. Each element of the array is itself an array with keys
// corresponding to the entry table columns
function get_entries_by_area(int $area_id, DateTime $start_date, DateTime $end_date) : array
{
// The GROUP BY is necessary to get n_registered to work
$sql = "SELECT E.*, " .
db()->syntax_timestamp_to_unix("E.timestamp") . " AS last_updated,
COUNT(P.entry_id) AS n_registered
FROM " . _tbl('entry') . " E
LEFT JOIN " . _tbl('room') . " R ON E.room_id = R.id
LEFT JOIN " . _tbl('participants') . " P ON P.entry_id = E.id
WHERE R.area_id = :area_id
AND R.disabled = 0
AND start_time < :interval_end AND end_time > :interval_start
GROUP BY E.id
ORDER BY start_time, E.id"; // necessary so that multiple bookings appear in the right order
$sql_params = [
':area_id' => $area_id,
':interval_end' => $end_date->getTimestamp(),
':interval_start' => $start_date->getTimestamp()
];
$res = db()->query($sql, $sql_params);
$result = $res->all_rows_keyed();
foreach ($result as &$row)
{
row_cast_columns($row, 'entry');
unpack_status($row);
}
unset($row);
return $result;
}
// Get the number of entries for the room with id $room_id
function get_n_entries_by_room(int $room_id) : int
{
$sql = "SELECT COUNT(*)
FROM " . _tbl('entry') . "
WHERE room_id=:room_id";
return db()->query1($sql, [':room_id' => $room_id]);
}
// Get all the entries for the room with $room_id in the interval that starts at $start_date (if set)
// and ends at $end_date (if set) (both DateTime objects). The sort order is ASC by default, but
// DESC if $desc is TRUE. If $limit is set then only $limit entries are returned.
// Returns an array of entries. Each element of the array is itself an array with keys
// corresponding to the entry table columns
function get_entries_by_room(int $room_id, ?DateTime $start_date, ?DateTime $end_date, bool $desc=false, ?int $limit=null) : array
{
$sql_params = [':room_id' => $room_id];
// The GROUP BY is necessary to get n_registered to work
$sql = "SELECT E.*, " .
db()->syntax_timestamp_to_unix("E.timestamp") . " AS last_updated,
COUNT(P.entry_id) AS n_registered
FROM " . _tbl('entry') . " E
LEFT JOIN " . _tbl('participants') . " P ON P.entry_id = E.id
WHERE room_id = :room_id";
if (isset($start_date))
{
$sql_params[':interval_start'] = $start_date->getTimestamp();
$sql .= " AND end_time > :interval_start";
}
if (isset($end_date))
{
$sql_params[':interval_end'] = $end_date->getTimestamp();
$sql .= " AND start_time < :interval_end";
}
$sql .= " GROUP BY E.id";
$order = ($desc) ? 'DESC' : 'ASC';
$sql .= " ORDER BY start_time $order, id $order"; // necessary so that multiple bookings appear in the right order
if (isset($limit))
{
// The LIMIT parameter should ideally be one of the parameters to the
// query, but MySQL throws an error at the moment because it gets bound
// as a string. Doesn't matter in this case because we know $limit is
// an integer, but for the general case MRBS needs to provide the ability
// to bind it as an integer.
$sql .= " LIMIT $limit";
}
$res = db()->query($sql, $sql_params);
$result = $res->all_rows_keyed();
foreach ($result as &$row)
{
row_cast_columns($row, 'entry');
unpack_status($row);
}
unset($row);
return $result;
}
/**
* Get the start and end times of all a user's entries. Entries in disabled roms and areas are ignored.
*
* @param string $create_by The username of the user whose entries are to be returned.
* @param int $interval_start The start of the interval to return entries for.
* @param int|null $interval_end The end of the interval to return entries for; if NULL, then all subsequent entries.
* @param int|null $area_id The area for which entries are to be returned. If NULL, then all areas.
* @param bool $exclude_periods Whether to exclude areas where periods are enabled.
* @param int|null $ignore_entry_id If set, then only entries that don't have this id are returned.
* @param int|null $ignore_repeat_id If set, then only entries that don't have this repeat id are returned.
* @param array $ignore_types Exclude entries of these types.
*
* @return array{array{start_time: int, end_time: int}} An array of arrays, each of which contains the start and end
* times of an entry.
*/
function get_entry_times_by_user(
string $create_by,
int $interval_start,
?int $interval_end=null,
?int $area_id=null,
bool $exclude_periods=false,
?int $ignore_entry_id=null,
?int $ignore_repeat_id=null,
array $ignore_types = []
) : array
{
$sql = "SELECT E.start_time, E.end_time
FROM " . _tbl('entry') . " E, " . _tbl('room') . " R, " . _tbl('area') . " A
WHERE E.end_time > :interval_start
AND E.create_by = :create_by
AND E.room_id = R.id
AND R.area_id = A.id
AND R.disabled = 0
AND A.disabled = 0";
$sql_params = array(
':interval_start' => $interval_start,
':create_by' => $create_by
);
if (isset($interval_end))
{
$sql .= " AND E.start_time < :interval_end";
$sql_params[':interval_end'] = $interval_end;
}
if ($exclude_periods)
{
// If we're checking for the actual time of bookings, then exclude areas using periods
$sql .= " AND A.enable_periods=0";
}
if (isset($area_id))
{
$sql .= " AND R.area_id = :area_id";
$sql_params[':area_id'] = $area_id;
}
if (isset($ignore_entry_id))
{
$sql .= " AND E.id <> :ignore_entry_id";
$sql_params[':ignore_entry_id'] = $ignore_entry_id;
}
if (isset($ignore_repeat_id))
{
$sql .= " AND (E.repeat_id IS NULL OR E.repeat_id <> :ignore_repeat_id)";
$sql_params[':ignore_repeat_id'] = $ignore_repeat_id;
}
if (!empty($ignore_types))
{
$sql .= " AND E.type NOT IN ('" . implode("', '", $ignore_types) . "')";
}
$res = db()->query($sql, $sql_params);
$result = array();
while (false !== ($row = $res->next_row_keyed()))
{
$result[] = $row;
}
return $result;
}
// Get the number of outstanding (ie waiting to be approved) for a user
function get_entries_n_outstanding(User $user) : int
{
$sql_approval_enabled = some_area_predicate('approval_enabled');
// Find out how many bookings are awaiting approval
// (but only for areas where approval is required)
$sql = "SELECT room_id, create_by
FROM " . _tbl('entry') . " E, " . _tbl('room') . " R, " . _tbl('area') . " A
WHERE (status&" . STATUS_AWAITING_APPROVAL . " != 0)
AND E.room_id = R.id
AND R.area_id = A.id
AND R.disabled = 0
AND A.disabled = 0
AND $sql_approval_enabled";
$res = db()->query($sql);
$result = 0;
// Get this user's own bookings awaiting approval or those made by others in rooms
// for which this user is a booking admin.
while (false !== ($row = $res->next_row_keyed()))
{
if ((compare_usernames($row['create_by'], $user->username) === 0) || is_book_admin($row['room_id']))
{
$result++;
}
}
return $result;
}
/** mrbsDelEntry()
*
* Delete an entry, or optionally all entries. Will also delete any newly
* orphaned rows in the repeat table.
*
* $id - The entry to delete
* $series - If set, delete the series, except user modified entries
* $all - If set, include user modified entries in the series delete
*
* Returns FALSE if an error occurred, otherwise an array of start_times that
* have been deleted.
*
*/
function mrbsDelEntry(int $id, bool $series, bool $all)
{
$start_times = array();
// Get the repeat_id and room_id for this entry
$sql = "SELECT repeat_id, room_id
FROM " . _tbl('entry') . "
WHERE id=?
LIMIT 1";
$res = db()->query($sql, array($id));
if ($res->count() <= 0)
{
return FALSE;
}
$row = $res->next_row_keyed();
row_cast_columns($row, 'entry');
$repeat_id = $row['repeat_id'];
$room_id = $row['room_id'];
$sql = "SELECT * FROM " . _tbl('entry') . " WHERE ";
$sql_params = array();
if ($series)
{
$sql .= "repeat_id=?";
$sql_params[] = $repeat_id;
}
else
{
$sql .= "id=?";
$sql_params[] = $id;
}
$res = db()->query($sql, $sql_params);
while (false !== ($row = $res->next_row_keyed()))
{
row_cast_columns($row, 'entry');
unpack_status($row);
if(!getWritable($row['create_by'], $room_id))
{
continue;
}
if ($series && $row['entry_type'] == ENTRY_RPT_CHANGED && !$all)
{
continue;
}
// check that the booking policies allow us to delete this entry
$tmp = mrbsCheckPolicy($row, null, null, true);
if (empty($tmp['errors']))
{
$sql = "DELETE FROM " . _tbl('entry') . " WHERE id=?";
if (db()->command($sql, array($row['id'])) > 0)
{
$start_times[] = $row['start_time'];
}
}
}
// Get rid of any orphaned rows in the repeat table
$sql = "SELECT COUNT(*)
FROM " . _tbl('entry') . "
WHERE repeat_id=?";
if (!empty($repeat_id) && (db()->query1($sql, array($repeat_id)) == 0))
{
$sql = "DELETE FROM " . _tbl('repeat') . " WHERE id=?";
db()->command($sql, array($repeat_id));
}
asort($start_times);
return $start_times;
}
/**
* Create an entry in the database
*
* @param string $table The table in which to create the entry
* @param array $data The entry data
* @return int The entry ID, or 0 on error
*/
function mrbsCreateEntry(string $table, array $data) : int
{
global $standard_fields;
$sql_col = array();
$sql_val = array();
$table_short_name = get_table_short_name($table);
$fields = db()->field_info($table);
$sql_params = array();
// Pack the status fields into the bit map
pack_status($data);
foreach ($fields as $field)
{
$key = $field['name'];
// Check to see whether custom field names obey the MRBS rules. If we don't check
// other parts of the code will run without producing PHP or SQL errors but will give
// the wrong results. A bit of a crude check, and in due course we probably ought to
// allow custom fields with spaces in them (or else have custom fields created by MRBS).
if (mb_strpos($key, ' ') !== FALSE)
{
Errors::fatalError("Custom field names cannot contain spaces.");
}
// If the key doesn't exist in the $data array then the database will just use its
// default value for the column. If it does exist and is set to NULL then we'll
// write NULL to the database (which may not necessarily be the default value).
if (array_key_exists($key, $data))
{
switch ($key)
{
// booleans
case 'allow_registration':
case 'registrant_limit_enabled':
case 'registration_opens_enabled':
case 'registration_closes_enabled':
if (isset($data[$key]))
{
$sql_col[] = $key;
$sql_val[] = '?';
$sql_params[] = ($data[$key]) ? 1 : 0;
}
break;
// integers
case 'start_time':
case 'end_time':
case 'entry_type':
case 'repeat_id':
case 'room_id':
case 'status':
case 'ical_sequence':
$sql_col[] = $key;
$sql_val[] = '?';
$sql_params[] = (isset($data[$key])) ? (int)$data[$key] : null;
break;
// integers NOT NULL
case 'registrant_limit':
case 'registration_opens':
case 'registration_closes':
if (isset($data[$key]))
{
$sql_col[] = $key;
$sql_val[] = '?';
$sql_params[] = (int)$data[$key];
}
break;
// strings
case 'create_by':
case 'modified_by':
case 'name':
case 'type':
case 'description':
case 'ical_uid':
case 'ical_recur_id':
$sql_col[] = $key;
$sql_val[] = '?';
$sql_params[] = (isset($data[$key])) ? $data[$key] : null;
break;
default:
// custom fields
// We handle the repeat data separately
$repeat_fields = [
'repeat_rule',
'rep_type',
'rep_opt',
'rep_interval',
'month_absolute',
'month_relative'
];
if (!in_array($key, $standard_fields[$table_short_name]) &&
!in_array($key, $repeat_fields))
{
$sql_col[] = $key;
// Depending on the nature of the custom field, the treatment will vary
switch ($field['nature'])
{
case 'integer':
if (!isset($data[$key]) || ($data[$key] === ''))
{
// Try and set it to NULL when we can because there will be cases when we
// want to distinguish between NULL and 0 - especially when the field
// is a genuine integer. Otherwise, set it to the default value if there
// is one, and failing that, 0.
$value = ($field['is_nullable']) ? null : ($field['default'] ?? 0);
}
else
{
$value = (int)$data[$key];
}
break;
default:
if (!isset($data[$key]))
{
$value = ($field['is_nullable']) ? null : ($field['default'] ?? '');
}
else
{
$value = $data[$key];
}
break;
} // switch ($field_natures[$key])
$sql_val[] = '?';
$sql_params[] = $value;
}
// do nothing for fields that aren't custom or otherwise listed above
break;
} // switch ($key)
} // if
} // foreach
// Add in the repeat data for the repeat table
if ($table_short_name == 'repeat')
{
$repeat_rule = $data['repeat_rule'];
$sql_col[] = 'rep_type';
$sql_val[] = '?';
$sql_params[] = $repeat_rule->getType();
$sql_col[] = 'end_date';
$sql_val[] = '?';
$end_date = $repeat_rule->getEndDate();
$sql_params[] = (isset($end_date)) ? $end_date->getTimestamp() : null;
// special case - rep_interval
$rep_interval = $repeat_rule->getInterval();
if (isset($rep_interval)) {
$sql_col[] = 'rep_interval';
$sql_val[] = '?';
$sql_params[] = $rep_interval;
}
$sql_col[] = 'rep_opt';
$sql_val[] = '?';
$sql_params[] = $repeat_rule->getRepOpt();
$sql_col[] = 'month_absolute';
$sql_val[] = '?';
$sql_params[] = $repeat_rule->getMonthlyAbsolute();
$sql_col[] = 'month_relative';
$sql_val[] = '?';
$sql_params[] = $repeat_rule->getMonthlyRelative();
}
foreach ($sql_col as &$col)
{
$col = db()->quote($col);
}
$sql = "INSERT INTO $table (" . implode(', ', $sql_col) . ") VALUES (" . implode(', ',$sql_val) . ")";
db()->command($sql, $sql_params);
return db()->insert_id($table, "id");
}
/** mrbsCreateSingleEntry()
*
* Create a single (non-repeating) entry in the database
*
* $data - An array containing the entry details
*
* Returns:
* 0 - An error occurred while inserting the entry
* non-zero - The entry's ID
*/
function mrbsCreateSingleEntry(array $data) : int
{
// Make sure that any entry is of a positive duration.
// This is to trap potential negative duration created when DST comes
// into effect
if ($data['end_time'] > $data['start_time'])
{
// If we're about to create an individual member of a series for the first time
// then give it a recurrence-id equivalent to the start time. It should always
// keep this value, even if the start time is subsequently changed.
if ($data['entry_type'] == ENTRY_RPT_ORIGINAL)
{
$data['ical_recur_id'] = Property::convertTimestamps($data['start_time'])[0];
}
return mrbsCreateEntry(_tbl('entry'), $data);
}
else
{
return 0;
}
}
/**
* Insert an entry into the repeat table.
*
* @return int The id of the newly created entry, or 0 on error.
*/
function mrbsCreateRepeatEntry(array $data) : int
{
return mrbsCreateEntry(_tbl('repeat'), $data);
}
/** mrbsCreateRepeatingEntrys()
*
* Creates a repeat entry in the database + all the repeating entries
*
* $data - An array containing the entry details
*
* Returns:
* an array
* ['id'] - 0 if an error occurred or if no bookings could be
* made, otherwise an id
* ['series'] - boolean: TRUE if the id refers to the repeat table
* FALSE if the id refers to the entry table
* ['start_times'] - an array of start times that have been created
*
*/
function mrbsCreateRepeatingEntrys(array $data) : array
{
global $max_rep_entrys;
$result = array('id' => 0, 'series' => false, 'start_times' => array());
if (!isset($data['skip_list']))
{
$data['skip_list'] = array();
}
$reps = $data['repeat_rule']->getRepeatStartTimes($data['start_time'], $max_rep_entrys);
// Don't make any bookings if
// (a) we've been asked to book up more entries than we are
// allowed in a single repeat, or
// (b) the repeat results in an empty set, or
// (c) we've got to skip past all the entries
if ((count($reps) > $max_rep_entrys) ||
(count($reps) == 0) ||
(count($reps) == count($data['skip_list'])))
{
$result['id'] = 0;
return $result;
}
// Maybe one should also consider adjusting the start_time for
// the repeat if the first (or more) entries of the series are
// to be skipped. However I haven't done so here and it gives the
// maybe slightly strange result that the start date of the series won't
// have an entry on that date. But then this is no different from
// the way MRBS works at present if you create a series and then
// delete the first entry.
//
// Note also that RFC 5545 allows this behaviour in 3.8.5.1:
// 'The "EXDATE" property can be used to exclude the value specified
// in "DTSTART". However, in such cases, the original "DTSTART" date
// MUST still be maintained by the calendaring and scheduling system
// because the original "DTSTART" value has inherent usage
// dependencies by other properties such as the "RECURRENCE-ID".'
$id = mrbsCreateRepeatEntry($data);
if ($id)
{
$data['entry_type'] = ENTRY_RPT_ORIGINAL;
$data['repeat_id'] = $id;
$start_time = $data['start_time'];
$end_time = $data['end_time'];
// Even if there is only one entry in the series we treat the booking
// as a series rather than a single booking. It allows the series to
// be extended later.
for ($i = 0; $i < count($reps); $i++)
{
// Provided this isn't one of the entries to skip, go ahead
// and make the booking
if (!in_array($reps[$i], $data['skip_list']))
{
// calculate diff each time and correct where events
// cross DST
$diff = $end_time - $start_time;
$diff += cross_dst($reps[$i], $reps[$i] + $diff);
$data['start_time'] = $reps[$i];
$data['end_time'] = $reps[$i] + $diff;
$ent_id = mrbsCreateSingleEntry($data);
$result['start_times'][] = $data['start_time'];
}
}
}
$result['id'] = $id;
$result['series'] = TRUE;
return $result;
}
// Gets the repeat_id for an entry in the entry table with id $entry_id
// Returns the repeat_id or NULL
function get_repeat_id(int $entry_id) : ?int
{
$sql = "SELECT repeat_id
FROM " . _tbl('entry') . "
WHERE id=?
LIMIT 1";
$res = db()->query($sql, array($entry_id));
if ($res->count() == 0)
{
// This should not happen
trigger_error('$entry_id=' . "$entry_id does not exist.", E_USER_NOTICE);
return null;
}
$row = $res->next_row_keyed();
return (isset($row['repeat_id'])) ? (int) $row['repeat_id'] : null;
}
function get_repeat(int $repeat_id) : ?array
{
$sql = "SELECT *
FROM " . _tbl('repeat') . "
WHERE id=:repeat_id
LIMIT 1";
$res = db()->query($sql, array(':repeat_id' => $repeat_id));
if (($res->count() == 0))
{
return null;
}
$row = $res->next_row_keyed();
row_cast_columns($row, 'repeat');
return $row;
}
// Update the time of last reminding.
// If the entry is part of a repeating series, then also increment
// the last reminder time in the repeat table and all the individual
// entries. (Although strictly speaking the reminder time should apply
// either to a series or an individual entry, we update everything to
// prevent users bombarding admins with reminder emails)
//
// Returns the number of tuples affected if OK (a number >= 0).
// Returns -1 on error; use the DB class error() method to get the error message.
function update_last_reminded($id, $series)
{
$now = time();
if ($series)
{
$sql = "UPDATE " . _tbl('repeat') . "
SET reminded=?,
ical_sequence=ical_sequence+1
WHERE id=?";
db()->command($sql, array($now, $id));
$sql = "UPDATE " . _tbl('entry') . "
SET reminded=?,
ical_sequence=ical_sequence+1
WHERE repeat_id=?";
return db()->command($sql, array($now, $id));
}
else
{
$sql = "UPDATE " . _tbl('entry') . "
SET reminded=?,
ical_sequence=ical_sequence+1
WHERE id=?";
if (db()->command($sql, array($now,$id)) > 0)
{
$repeat_id = get_repeat_id($id);
if (isset($repeat_id))
{
$sql = "UPDATE " . _tbl('repeat') . "
SET reminded=?,
ical_sequence=ical_sequence+1
WHERE id=?";
return db()->command($sql, array($now,$id));
}
}
}
return -1;
}
// Update the entry/repeat tables with details about the last More Info
// request (time, user, email text)
//
// If $series is TRUE then $id is the id of an entry in the repeat table
// which is updated. Otherwise $id is the id of an entry in the
// entry table, which is updated.
//
// Returns the number of tuples affected if OK (a number >= 0).
// Returns -1 on error; use the DB class error() method to get the error
// message.
function update_more_info($id, $series, $user, $note)
{
$table = ($series) ? _tbl('repeat') : _tbl('entry');
$now = time();
$sql_params = array();
$sql = "UPDATE $table SET";
$sql .= " info_time=?";
$sql_params[] = $now;
$sql .= ", info_user=?";
$sql_params[] = $user;
$sql .= ", info_text=?";
$sql_params[] = $note;
$sql .= " WHERE id=?";
$sql_params[] = $id;
return db()->command($sql, $sql_params);
}
// mrbsApproveEntry($id, $series)
//
// Approve an entry with id $id. If series is set to TRUE
// then the id is the id in the repeat table and we must approve
// all the individual entries.
// We also update the ical_sequence number so that any emails that
// are generated will be treated by calendar clients as referring
// to the same meeting, rather than a new meeting.
//
// Returns FALSE on failure, otherwise an array of start times that
// have been approved
function mrbsApproveEntry($id, $series)
{
if ($series)
{
// First update the repeat table if it's a series
$sql = "UPDATE " . _tbl('repeat') . "
SET status=status&(~" . STATUS_AWAITING_APPROVAL . "),
ical_sequence=ical_sequence+1
WHERE id=?"; // PostgreSQL does not support LIMIT with UPDATE
db()->command($sql, array($id));
$id_column = 'repeat_id';
}
else
{
$id_column = 'id';
}
// Then update the entry table. First of all we get a list of the
// start times that will be approved, then we do the approval.
$condition = "$id_column=? AND status&" . STATUS_AWAITING_APPROVAL . "!=0";
$sql_params = array($id);
$sql = "SELECT start_time
FROM " . _tbl('entry') . "
WHERE $condition";
$start_times = db()->query_array($sql, $sql_params);
if (($start_times !== FALSE) && (count($start_times) != 0))
{
$sql = "UPDATE " . _tbl('entry') . "
SET status=status&(~" . STATUS_AWAITING_APPROVAL . "),
ical_sequence=ical_sequence+1
WHERE $condition"; // PostgreSQL does not support LIMIT with UPDATE
db()->command($sql, $sql_params);
}
if (is_array($start_times))
{
asort($start_times);
}
return $start_times;
}
function get_registration_by_id(int $id) : ?array
{
$sql = "SELECT *
FROM " . _tbl('participants') . "
WHERE id=?
LIMIT 1";
$res = db()->query($sql, array($id));
if ($res->count() == 0)
{
return null;
}
$row = $res->next_row_keyed();
row_cast_columns($row, 'participants');
return $row;
}
// Get the people registered for an event
function get_registrants(int $id, bool $series) : ?array
{
// You can't register for a series (yet)
if ($series)
{
return null;
}
$sql = "SELECT *
FROM " . _tbl('participants') . "
WHERE entry_id=:entry_id";
$res = db()->query($sql, array(':entry_id' => $id));
$result = array();
while (false !== ($row = $res->next_row_keyed()))
{
row_cast_columns($row, 'participants');
$result[] = $row;
}
return $result;
}
// Check whether an entry has any registrants
function entry_has_registrants(int $entry_id) : bool
{
$sql = "SELECT COUNT(username)
FROM " . _tbl('entry') . " E
LEFT JOIN " . _tbl('participants') . " P ON E.id=P.entry_id
WHERE E.id=:entry_id
LIMIT 1";
$sql_params = array(':entry_id' => $entry_id);
return (db()->query1($sql, $sql_params) > 0);
}
// Check whether any entries in a series have users registered
function series_has_registrants(int $repeat_id) : bool
{
$sql = "SELECT COUNT(username)
FROM " . _tbl('entry') . " E
LEFT JOIN " . _tbl('participants') . " P ON E.id=P.entry_id
WHERE E.repeat_id=:repeat_id
LIMIT 1";
$sql_params = array(':repeat_id' => $repeat_id);
return (db()->query1($sql, $sql_params) > 0);
}
// Determines whether any entries allow registration
function registration_somewhere() : bool
{
$sql = "SELECT COUNT(*)
FROM " . _tbl('entry') . "
WHERE allow_registration=1
LIMIT 1";
return (db()->query1($sql) > 0);
}
// get_booking_info($id, $series)
//
// Gets all the details for a booking with $id, which is in the
// repeat table if $series is set, otherwise in the entry table.
// Returns the results in an array with keys the same as the table
// field names. In the event of an error stops with a fatal error,
// unless $silent is TRUE, when it returns FALSE.
function get_booking_info(int $id, bool $series, bool $silent=false)
{
// Check that we've got an id
if (!isset($id))
{
trigger_error("id not set", E_USER_WARNING);
if ($silent)
{
return false;
}
else
{
Errors::fatalError(($series ? get_vocab("invalid_series_id") : get_vocab("invalid_entry_id")));
}
}
$table = ($series) ? _tbl('repeat') : _tbl('entry');
$columns = Columns::getInstance($table);
if (!$series)
{
// Build an array of the column names in the repeat table so that
// we'll be able to do some sanity checking later
$repeat_columns = Columns::getInstance(_tbl('repeat'));
$repeat_column_names = $repeat_columns->getNames();
}
$terms = array(
'M.room_name',
'M.room_admin_email',
'M.area_id',
'A.area_name',
'A.area_admin_email',
'M.disabled AS room_disabled',
'A.disabled AS area_disabled',
'A.enable_periods',
'(end_time - start_time) AS duration'
);
foreach ($columns as $column)
{
switch ($column->name)
{
// these fields only exist in the entry table
case 'entry_type':
case 'repeat_id':
case 'ical_recur_id':
case 'allow_registration':
case 'registrant_limit':
case 'registrant_limit_enabled':
case 'registration_opens':
case 'registration_opens_enabled':
case 'registration_closes':
case 'registration_closes_enabled':
array_push($terms, $column->name);
break;
case 'timestamp':
array_push($terms, db()->syntax_timestamp_to_unix("timestamp") . " AS last_updated");
break;
case 'info_time':
case 'info_user':
case 'info_text':
if ($series)
{
array_push($terms, $column->name . " AS repeat_" . $column->name);
}
else
{
array_push($terms, $column->name . " AS entry_" . $column->name);
}
break;
default:
// These are (a) all the standard fields which are common to the entry
// and repeat tables and (b) all the custom fields, which should be
// common to the two tables (we will do a check to make sure)
if (!$series && !in_array($column->name, $repeat_column_names))
{
// If this is the entry table then check that the custom field also
// exists in the repeat table
Errors::fatalError("Custom fields problem, '" . $column->name . "' exists in entry table but not in repeat table");
}
array_push($terms, "T." . db()->quote($column->name));
break;
}
}
$sql = "SELECT " . implode(", ",$terms)."
FROM $table T, " . _tbl('room') . " M, " . _tbl('area') . " A
WHERE T.room_id = M.id
AND M.area_id = A.id
AND T.id=?";
try
{
$res = db()->query($sql, array($id));
}
catch (DBException $e)
{
// Not sure why we've got the silent option in here. Need to revisit this sometime.
trigger_error(db()->error(), E_USER_WARNING);
if ($silent)
{
return false;
}
else
{
throw $e;
}
}
$row = $res->next_row_keyed();
unset($res);
if (!is_array($row) || (!is_visible($row['room_id'])))
{
// It's quite possible that the id will have disappeared, eg if somebody
// else has deleted or edited the entry, or if the back button is pressed
// after deleting an entry.
if ($silent)
{
return false;
}
else
{
Errors::fatalError(($series ? get_vocab("invalid_series_id") : get_vocab("invalid_entry_id")));
}
}
// Cast the columns to their intended types
row_cast_columns($row, ($series) ? 'repeat' : 'entry');
// These two columns need special casting as they have been renamed
if (isset($row['entry_info_time']))
{
$row['entry_info_time'] = intval($row['entry_info_time']);
}
if (isset($row['repeat_info_time']))
{
$row['repeat_info_time'] = intval($row['repeat_info_time']);
}
$row['area_id'] = intval($row['area_id']); // Necessary for some databases
$row['enable_periods'] = boolval($row['enable_periods']);
// Turn the last_updated column into an int (some MySQL drivers will return a string,
// and it won't have been caught by row_cast_columns as it's a derived result).
$row['last_updated'] = intval($row['last_updated']);
// Fix a problem in earlier versions of MRBS that caused the end time for periods bookings
// that crossed a DST boundary in areas with a single period to be incorrectly recorded in
// the database (see GitHub Issue #3628). Round the endtime down to the end of the nearest
// valid period.
// TODO: This is only a temporary fix. We should really do a one-off clean-up of the database
// TODO: via an upgrade, but this will probably have to wait until the roles branch is
// TODO: reintegrated.
if ($row['enable_periods'])
{
$period_names = get_period_names();
$n_periods = count($period_names[$row['area_id']]);
$area_details = get_area_details($row['area_id']);
$end_date = new DateTime('now', new DateTimeZone($area_details['timezone']));
$end_date->setTimestamp($row['end_time']);
$hour = $end_date->getHour();
// If the time is between midnight and the start of the first period, then
// wind the end date back to the end of the previous day.
if ($hour < 12)
{
$end_date->setTime(23, 59);
$end_date->modify('-1 day');
}
// And if the time is now past the end of the last period, then wind it
// back to that point and adjust the end time.
$end_last_period = clone $end_date;
$end_last_period->setTime(12, $n_periods);
if ($end_date > $end_last_period)
{
$row['end_time'] = $end_last_period->getTimestamp();
}
}
// Now get the duration.
// Don't translate the units at this stage. We'll translate them later.
$d = get_duration($row['start_time'], $row['end_time'], $row['enable_periods'], $row['area_id'], false);
$row['duration'] = $d['duration'];
$row['dur_units'] = $d['dur_units'];
// Translate the status field into a set of booleans
unpack_status($row);
// Get some extra information
$row['registrants'] = get_registrants($id, $series);
if ($series)
{
$row['entry_info_time'] = '';
$row['entry_info_user'] = '';
$row['entry_info_text'] = '';
}
else
{
// Get the repeat information
if (!isset($row['repeat_id']))
{
$row['repeat_info_time'] = '';
$row['repeat_info_user'] = '';
$row['repeat_info_text'] = '';
}
else
{
$sql = "SELECT rep_type, end_date, rep_opt, rep_interval, month_absolute, month_relative,
info_time AS repeat_info_time, info_user AS repeat_info_user,
info_text AS repeat_info_text
FROM " . _tbl('repeat') . "
WHERE id=?
LIMIT 1";
$res = db()->query($sql, array($row['repeat_id']));
if (!$extra_row = $res->next_row_keyed())
{
if (!$res)
{
trigger_error(db()->error(), E_USER_WARNING);
}
if ($silent)
{
return false;
}
else
{
Errors::fatalError(get_vocab("invalid_series_id"));
}
}
// Cast the columns to their intended types
row_cast_columns($extra_row, 'repeat');
$row['repeat_info_time'] = $extra_row['repeat_info_time'];
$row['repeat_info_user'] = $extra_row['repeat_info_user'];
$row['repeat_info_text'] = $extra_row['repeat_info_text'];
unset($res);
}
}
// Add the repeat information
$repeat_rule = new RepeatRule();
if (!$series && !isset($row['repeat_id']))
{
$repeat_rule->setType(RepeatRule::NONE);
}
else
{
$repeat_row = ($series) ? $row : $extra_row;
$repeat_rule->setType($repeat_row['rep_type']);
$end_date = new DateTime();
$end_date->setTimestamp($repeat_row['end_date']);
$repeat_rule->setEndDate($end_date);
$repeat_rule->setInterval($repeat_row['rep_interval']);
$repeat_rule->setDaysFromOpt($repeat_row['rep_opt']);
if ($repeat_row['rep_type'] == RepeatRule::MONTHLY) {
if (isset($repeat_row['month_absolute'])) {
$repeat_rule->setMonthlyType(RepeatRule::MONTHLY_ABSOLUTE);
$repeat_rule->setMonthlyAbsolute($repeat_row['month_absolute']);
}
else {
$repeat_rule->setMonthlyType(RepeatRule::MONTHLY_RELATIVE);
$repeat_rule->setMonthlyRelative($repeat_row['month_relative']);
}
}
}
$row['repeat_rule'] = $repeat_rule;
// TODO: remove temporary code when the transition to RepeatRule is complete
$row = RepeatRule::fixUp($row);
return $row;
}
// Gets the id of an area with a given name.
// Returns NULL or the id.
function get_area_id(string $area_name) : ?int
{
$sql = "SELECT id
FROM " . _tbl('area') . "
WHERE area_name=?
LIMIT 1";
$res = db()->query($sql, array($area_name));
return ($res->count() == 0) ? null : intval($res->next_row()[0]);
}
// Get the name of the area with id $id. If $all is set to TRUE then all areas
// are searched, otherwise just those which have not been disabled
// Returns FALSE if there is an error, NULL if the id does not exist
function get_area_name(int $id, bool $all=false) : ?string
{
$id = (int)$id;
$sql = "SELECT area_name
FROM " . _tbl('area') . "
WHERE id=?";
if (empty($all))
{
$sql .= " AND disabled=0";
}
$sql .= " LIMIT 1";
$res = db()->query($sql, array($id));
return ($res->count() == 0) ? null : $res->next_row()[0];
}
// Gets an array of area names indexed by area id.
// If $all=TRUE then all areas are returned, otherwise just the ones that
// are not disabled
function get_area_names(bool $all=false) : array
{
$areas = array();
$sql = "SELECT id, area_name FROM " . _tbl('area');
if (empty($all))
{
$sql .= " WHERE disabled=0";
}
$sql .= " ORDER BY sort_key";
$res = db()->query($sql);
while (false !== ($row = $res->next_row_keyed()))
{
$areas[$row['id']] = $row['area_name'];
}
return $areas;
}
// Get all the area details for $area_id.
// Returns NULL if $area_id does not exist.
function get_area_details(int $area_id) : ?array
{
global $area_defaults;
$sql = "SELECT *
FROM " . _tbl('area') . "
WHERE id=?
LIMIT 1";
$res = db()->query($sql, array($area_id));
if ($res->count() == 0)
{
return null;
}
$row = $res->next_row_keyed();
row_cast_columns($row, 'area');
// It's possible that the timezone could be NULL on systems upgraded from early versions
// of MRBS before the timezone column was in the area table and if the area details have
// not been edited since the upgrade. (The column should have been populated with the
// system timezone on upgrade, but wasn't. It's too complicated to add another upgrade
// step to fix this until the roles branch is merged back into main.)
if (!isset($row['timezone']))
{
$row['timezone'] = $area_defaults['timezone'];
}
return $row;
}
// Get the name of the room with id $room_id. If $all is set to TRUE then all rooms
// are searched, otherwise just those which have not been disabled
// Returns NULL if the id does not exist
function get_room_name(int $room_id, bool $all=false) : ?string
{
$room_id = (int)$room_id;
$sql = "SELECT room_name
FROM " . _tbl('room') . " R, " . _tbl('area') . " A
WHERE R.id=?
AND R.area_id=A.id";
if (empty($all))
{
$sql .= " AND R.disabled=0 AND A.disabled=0";
}
$sql .= " LIMIT 1";
$res = db()->query($sql, array($room_id));
return ($res->count() == 0) ? null : $res->next_row()[0];
}
// Gets an array of room names in an area indexed by room id.
// If $all=TRUE then all areas are returned, otherwise just the ones that
// are not disabled
function get_room_names(int $area_id, bool $all=false) : array
{
$rooms = array();
$sql = "SELECT id, room_name
FROM " . _tbl('room') . "
WHERE area_id = :area_id";
if (empty($all))
{
$sql .= " AND disabled=0";
}
$sql .= " ORDER BY sort_key";
$res = db()->query($sql, array(':area_id' => $area_id));
while (false !== ($row = $res->next_row_keyed()))
{
if (is_visible($row['id']))
{
$rooms[$row['id']] = $row['room_name'];
}
}
return $rooms;
}
/**
* Retrieves the details of a room by its ID.
*
* @return array|null An associative array containing room details, or null if the room is not visible or doesn't exist.
*/
function get_room_details(int $room_id) : ?array
{
if (!is_visible($room_id))
{
return null;
}
$sql = "SELECT *
FROM " . _tbl('room') . "
WHERE id=?
LIMIT 1";
$res = db()->query($sql, array($room_id));
if (false === ($row = $res->next_row_keyed()))
{
$details = null;
}
else
{
row_cast_columns($row, 'room');
$details = $row;
}
// The invalid types column is json_encoded
if (isset($details['invalid_types']))
{
$details['invalid_types'] = json_decode($details['invalid_types']);
}
return $details;
}
// Gets the rooms in an area
// If $include_disabled is TRUE then all rooms are returned, whether or not they are disabled,
// otherwise just the ones that are not disabled.
// If $include_invisible is TRUE then all rooms are returned, whether or not they are visible,
// otherwise just the ones that are visible to the current user.
function get_rooms(int $area, bool $include_disabled=false, bool $include_invisible=false) : array
{
$sql = "SELECT R.*
FROM " . _tbl('room') . " R, " . _tbl('area') . " A
WHERE R.area_id=?
AND R.area_id = A.id";
if (!($include_disabled))
{
$sql .= " AND R.disabled = 0" .
" AND A.disabled = 0";
}
// In early versions of MRBS the sort_key field didn't exist and this function
// is called before the database can be upgraded.
if (db()->field_exists(_tbl('room'), 'sort_key'))
{
$sql .= " ORDER BY R.sort_key";
}
$res = db()->query($sql, array($area));
$result = array();
while (false !== ($row = $res->next_row_keyed()))
{
row_cast_columns($row, 'room');
if ($include_invisible || is_visible($row['id']))
{
if (isset($row['invalid_types']))
{
$row['invalid_types'] = json_decode($row['invalid_types']);
}
$result[] = $row;
}
}
return $result;
}
// Gets the ids of all the invisible rooms in the system
function get_invisible_room_ids() : array
{
$sql = "SELECT id FROM " . _tbl('room');
$res = db()->query($sql);
$result = array();
while (false !== ($row = $res->next_row_keyed()))
{
if (!is_visible($row['id']))
{
$result[] = $row['id'];
}
}
return $result;
}
/**
* Get the period data for a room.
*
* @return array{enable_periods: bool, periods: Periods}
*/
function get_period_data(int $room_id) : array
{
$sql = "SELECT M.area_id, enable_periods, periods
FROM " ._tbl('area') . " A, ". _tbl('room') . " M
WHERE A.id = M.area_id
AND M.id = :room_id
LIMIT 1";
$res = db()->query($sql, [':room_id' => $room_id]);
$row = $res->next_row_keyed();
row_cast_columns($row, 'room');
$row['enable_periods'] = (bool) $row['enable_periods'];
$row['periods'] = Periods::fromDbValue($row['area_id'], $row['periods']);
return $row;
}
// Gets a two-dimensional array of period names indexed by area id.
function get_period_names() : array
{
static $period_names = null;
if (isset($period_names))
{
return $period_names;
}
$period_names = array();
$sql = "SELECT id, periods FROM " . _tbl('area');
$res = db()->query($sql);
while (false !== ($row = $res->next_row_keyed()))
{
$period_names[$row['id']] = json_decode($row['periods'], true);
}
return $period_names;
}
// Adds an area, returning the new id, or FALSE on failure with the
// error in $error
function mrbsAddArea($name, &$error)
{
global $area_defaults, $boolean_fields;
// First of all check that we've got a name
if (!isset($name) || ($name === ''))
{
$error = "empty_name";
return false;
}
// Truncate the name field to the maximum length as a precaution.
$name = mb_substr($name, 0, maxlength('area.area_name'));
// Insert the area into the database. We insert the area name that we have
// been given, together with the default values for the per-area settings.
// Build arrays of data to be inserted into the table
$sql_params = array();
$sql_col = array();
$sql_val = array();
// Get the information about the fields in the room table
$fields = db()->field_info(_tbl('area'));
// Loop through the fields and build up the arrays
foreach ($fields as $field) {
$key = $field['name'];
switch ($key) {
case 'area_name':
case 'sort_key':
$sql_col[] = $key;
$sql_val[] = "?";
$sql_params[] = $name;
break;
case 'periods':
$sql_col[] = $key;
$sql_val[] = "?";
$sql_params[] = json_encode($area_defaults[$key]);
break;
default:
if (array_key_exists($key, $area_defaults)) {
$sql_col[] = $key;
$sql_val[] = "?";
if (in_array($key, $boolean_fields['area'])) {
$sql_params[] = ($area_defaults[$key]) ? 1 : 0;
}
else {
$sql_params[] = $area_defaults[$key];
}
}
break;
}
}
try {
$sql = "INSERT INTO " . _tbl('area') . " (" . implode(', ', $sql_col) . ")
VALUES (" . implode(', ', $sql_val) . ")";
db()->command($sql, $sql_params);
$area_id = db()->insert_id(_tbl('area'), 'id');
}
catch (DBException $e) {
// Check whether the area name is unique and if not return an
// appropriate error
$sql = "SELECT COUNT(*)
FROM " . _tbl('area') . "
WHERE area_name=?
LIMIT 1";
if (db()->query1($sql, array($name)) > 0)
{
$error = "invalid_area_name";
return false;
}
// Otherwise re-throw the exception
throw $e;
}
return $area_id;
}
// Adds a room, returning the new id, or FALSE on failure with the
// error in $error
function mrbsAddRoom($name, $area, &$error, $description='', $capacity='', $room_admin_email='')
{
// First of all check that we've got a name
if (!isset($name) || ($name === '')) {
$error = "empty_name";
return FALSE;
}
// Truncate the name and description fields to the maximum length as a precaution.
$name = mb_substr($name, 0, maxlength('room.room_name'));
$description = mb_substr($description, 0, maxlength('room.description'));
if (empty($capacity))
{
$capacity = 0;
}
try {
// Insert the room into the database
$sql = "INSERT INTO " . _tbl('room') . " (room_name, sort_key, area_id, description, capacity, room_admin_email)
VALUES (?, ?, ?, ?, ?, ?)";
db()->command($sql, array($name, $name, $area, $description, $capacity, $room_admin_email));
$room_id = db()->insert_id(_tbl('room'), 'id');
}
catch (DBException $e) {
// Check whether the room name is unique within the area and
// if not return an appropriate error
$sql = "SELECT COUNT(*)
FROM " . _tbl('room') . "
WHERE room_name=? AND area_id=?
LIMIT 1";
if (db()->query1($sql, array($name, $area)) > 0) {
$error = "invalid_room_name";
return false;
}
// Otherwise re-throw the exception
throw $e;
}
return $room_id;
}
function update_participants(int $old_id, int $new_id) : bool
{
$sql = "UPDATE " . _tbl('participants') . "
SET entry_id=:new_id
WHERE entry_id=:old_id";
$sql_params = array(':new_id' => $new_id, ':old_id' => $old_id);
try
{
db()->command($sql, $sql_params);
}
catch (DBException $exception)
{
trigger_error($exception->getMessage(), E_USER_WARNING);
return false;
}
return true;
}
// Makes bookings
// $bookings an array of bookings
// $id the id of the current booking when editing an existing entry
function mrbsMakeBookings(array $bookings, ?int $id=null, bool $just_check=false, bool $skip=false, ?int $original_room_id=null, bool $send_mail=false, bool $edit_series=false)
{
global $max_rep_entrys, $enable_periods, $resolution, $mail_settings;
global $auth;
// All the data, except for the status and room id, will be common
// across the bookings
$common = $bookings[0];
// Work out the duration in seconds, but adjust it for DST changes so that
// the user will still see, for example, "24 hours" when a booking goes from
// 1200 one day to 1200 the next, crossing a DST boundary.
$duration_seconds = $common['end_time'] - $common['start_time'];
$duration_seconds -= cross_dst($common['start_time'], $common['end_time']);
// Now get the duration, which will be needed for email notifications
// (We do this before we adjust for DST so that the user sees what they expect to see)
$duration = $duration_seconds;
$date = getdate($common['start_time']);
if ($enable_periods)
{
$period = (($date['hours'] - 12) * 60) + $date['minutes'];
toPeriodString($period, $duration, $dur_units, FALSE);
}
else
{
toTimeString($duration, $dur_units, FALSE);
}
// Expand a series into a list of start times:
if ($bookings[0]['repeat_rule']->getType() != RepeatRule::NONE)
{
$reps = $bookings[0]['repeat_rule']->getRepeatStartTimes($common['start_time'], $max_rep_entrys);
}
// When checking for overlaps, for Edit (not New), ignore this entry and series:
if (isset($id))
{
$ignore_entry_id = $id;
$repeat_id = get_repeat_id($id);
}
else
{
$ignore_entry_id = null;
$repeat_id = null;
}
// Validate the booking for (a) conflicting bookings and (b) conformance to policy rules
$valid_booking = true;
$conflicts = array(); // Holds a list of all the conflicts
$violations = array('notices' => array(), // Holds an array of the policy rules that have been broken
'errors' => array()); // classified by 'notices' and 'errors'
$skip_lists = array(); // Holds a 2D array of bookings to skip past. Indexed
// by room id and start time
// Check for selection of multiple rooms.
// The only question is whether this a notice or an error. If the user is an ordinary user
// in more than one of the rooms, then it's an error, otherwise a notice.
if ($auth['only_admin_can_select_multiroom'] && (count($bookings) > 1))
{
$violation_status = 'notices';
$non_admin_rooms = 0;
foreach($bookings as $booking)
{
if (!is_book_admin($booking['room_id']))
{
$non_admin_rooms++;
if ($non_admin_rooms > 1)
{
$violation_status = 'errors';
$valid_booking = false;
break;
}
}
}
$violations[$violation_status][] = get_vocab('multiroom_not_allowed');
}
// Check for any schedule conflicts in each room we're going to try and
// book in; also check that the booking conforms to the policy
foreach ($bookings as $booking)
{
$skip_lists[$booking['room_id']] = $booking['skip_list'] ?? [];
if ($booking['repeat_rule']->getType() != RepeatRule::NONE && !empty($reps))
{
if(count($reps) < $max_rep_entrys)
{
for ($i = 0; $i < count($reps); $i++)
{
// Ignore conflicts in the booking's skip list, because they won't be made.
if (isset($booking['skip_list']) && in_array($reps[$i], $booking['skip_list']))
{
continue;
}
// Calculate diff each time and correct where events cross DST
$diff = $duration_seconds;
$diff += cross_dst($reps[$i], $reps[$i] + $diff);
$this_booking = $booking;
$this_booking['start_time'] = $reps[$i];
$this_booking['end_time'] = $reps[$i] + $diff;
// Check that this entry doesn't overlap with the next in the series.
// (This can happen if people mistake the end date for the repeat end date.)
if (isset($reps[$i+1]) && ($this_booking['end_time'] > $reps[$i+1]))
{
$valid_booking = false;
$conflicts[] = get_vocab('conflicts_with_self');
break;
}
$tmp = mrbsCheckFree($this_booking, $ignore_entry_id, $repeat_id);
$skip_this_booking = FALSE;
if (!empty($tmp))
{
// If we've been told to skip past existing bookings, then add
// this start time to the list of start times to skip past.
// Otherwise it's an invalid booking
if ($skip)
{
$skip_lists[$this_booking['room_id']][] = $this_booking['start_time'];
$skip_this_booking = TRUE;
}
else
{
$valid_booking = FALSE;
}
// In both cases remember the conflict data. (We don't at the
// moment do anything with the data if we're skipping, but we might
// in the future want to display a list of bookings we've skipped past)
$conflicts = array_merge($conflicts, $tmp);
}
// If we're not going to skip past this booking, check that the booking
// conforms to the booking policy. (If we're going to skip past this
// booking then it doesn't matter whether or not it conforms to the policy
// because we're never going to make it)
if (!$skip_this_booking)
{
$violations_this_booking = mrbsCheckPolicy($this_booking, $ignore_entry_id, $repeat_id);
foreach (array('notices', 'errors') as $key)
{
$violations[$key] = array_merge($violations[$key], $violations_this_booking[$key]);
}
if (!empty($violations_this_booking['errors']))
{
$valid_booking = FALSE;
}
}
} // for
}
else
{
if (is_book_admin($booking['room_id']))
{
$violation_status = 'notices';
}
else
{
$violation_status = 'errors';
$valid_booking = false;
}
$violations[$violation_status][] = get_vocab("too_many_entries", $max_rep_entrys - 1);
}
}
else
{
$tmp = mrbsCheckFree($booking, $ignore_entry_id);
if (!empty($tmp))
{
$valid_booking = FALSE;
$conflicts = array_merge($conflicts, $tmp);
}
// check that the booking conforms to the booking policy
$violations_this_booking = mrbsCheckPolicy($booking, $ignore_entry_id, 0);
foreach (array('notices', 'errors') as $key)
{
$violations[$key] = array_merge($violations[$key], $violations_this_booking[$key]);
}
if (!empty($violations_this_booking['errors']))
{
$valid_booking = FALSE;
}
}
} // end foreach bookings
// If we are editing an existing booking then we need to check that we are
// allowed to delete it
if (isset($id))
{
if ($edit_series)
{
$old_bookings = get_bookings_in_series($repeat_id);
}
else
{
$old_bookings = array();
$old_booking = get_entry_by_id($id);
if (isset($old_booking))
{
$old_bookings[] = $old_booking;
}
}
foreach ($old_bookings as $old_booking)
{
$violations_this_booking = mrbsCheckPolicy($old_booking, null, null, true);
foreach (array('notices', 'errors') as $key)
{
$violations[$key] = array_merge($violations[$key], $violations_this_booking[$key]);
}
if (!empty($violations_this_booking['errors']))
{
$valid_booking = FALSE;
}
}
}
// Tidy up the lists of conflicts and rules broken, getting rid of duplicates
$conflicts = array_values(array_unique($conflicts));
foreach (array('notices', 'errors') as $key)
{
$violations[$key] = array_values(array_unique($violations[$key]));
}
$result = array();
$result['valid_booking'] = $valid_booking;
$result['violations'] = $violations;
$result['conflicts'] = $conflicts;
// If we've just been asked to check the bookings, or if it wasn't a valid
// booking, then stop here and return the results
if ($just_check || !$valid_booking)
{
return $result;
}
// Otherwise we go on to commit the booking
$new_details = array(); // We will pass this array in the Ajax result
$rooms = array();
foreach ($bookings as $booking)
{
$rooms[] = $booking['room_id'];
}
foreach ($bookings as $booking)
{
// We need to work out whether this is the original booking being modified,
// because, if it is, we keep the ical_uid and increment the ical_sequence.
// We consider this to be the original booking if there was an original
// booking in the first place (in which case the original room id will be set) and
// (a) this is the same room as the original booking
// or (b) there is only one room in the new set of bookings, in which case
// what has happened is that the booking has been changed to be in
// a new room
// or (c) the new set of rooms does not include the original room, in which
// case we will make the arbitrary assumption that the original booking
// has been moved to the first room in the list and the bookings in the
// other rooms are clones and will be treated as new bookings.
if (isset($original_room_id) &&
(($original_room_id == $booking['room_id']) ||
(count($rooms) == 1) ||
(($rooms[0] == $booking['room_id']) && !in_array($original_room_id, $rooms))))
{
// This is an existing booking which has been changed. Keep the
// original ical_uid and increment the sequence number.
$booking['ical_sequence']++;
}
else
{
// This is a new booking. We generate a new ical_uid and start
// the sequence at 0 - unless there already are uid and sequence
// numbers, for example when importing an iCalendar file
if (empty($booking['ical_uid']))
{
$booking['ical_uid'] = generate_global_uid($booking['name']);
}
if (empty($booking['ical_sequence']))
{
$booking['ical_sequence'] = 0;
}
}
if ($booking['repeat_rule']->getType() == RepeatRule::NONE)
{
$booking['entry_type'] = (isset($repeat_id)) ? ENTRY_RPT_CHANGED : ENTRY_SINGLE;
$booking['repeat_id'] = $repeat_id;
}
// Add in the list of bookings to skip
if (!empty($skip_lists) && !empty($skip_lists[$booking['room_id']]))
{
$booking['skip_list'] = $skip_lists[$booking['room_id']];
}
// The following elements are needed for email notifications
$booking['duration'] = $duration;
$booking['dur_units'] = $dur_units;
if ($booking['repeat_rule']->getType() != RepeatRule::NONE)
{
$details = mrbsCreateRepeatingEntrys($booking);
$new_id = $details['id'];
$is_repeat_table = $details['series'];
asort($details['start_times']);
$result['start_times'] = $details['start_times'];
}
else
{
// Make sure the keys below are set properly for a single entry (they might not
// be if we've been editing a series and changed its repeat type to "none").
$booking['repeat_id'] = null;
$booking['entry_type'] = ENTRY_SINGLE;
// Create the entry
$new_id = mrbsCreateSingleEntry($booking);
// Update the participants table
if (isset($id))
{
update_participants($id, $new_id);
}
$is_repeat_table = FALSE;
$result['start_times'] = array($booking['start_time']);
}
$new_details[] = array('id' => $new_id, 'room_id' => $booking['room_id']);
$booking['id'] = $new_id; // Add in the id now we know it
// Send an email if necessary, provided that the entry creation was successful
if ($send_mail && !empty($new_id))
{
// Only send an email if (a) this is a changed entry and we have to send emails
// on change or (b) it's a new entry and we have to send emails for new entries
if ((isset($id) && $mail_settings['on_change']) ||
(!isset($id) && $mail_settings['on_new']))
{
require_once "functions_mail.inc";
// Get room name and area name for email notifications.
$sql = "SELECT R.room_name, A.area_name
FROM " . _tbl('room') . " R, " . _tbl('area') . " A
WHERE R.id=? AND R.area_id = A.id
LIMIT 1";
$res = db()->query($sql, array($booking['room_id']));
$row = $res->next_row_keyed();
$booking['room_name'] = $row['room_name'];
$booking['area_name'] = $row['area_name'];
// If this is a modified entry then get the previous entry data
// so that we can highlight the changes
if (isset($id))
{
if ($edit_series)
{
$mail_previous = get_booking_info($repeat_id, TRUE);
}
else
{
$mail_previous = get_booking_info($id, FALSE);
}
}
else
{
$mail_previous = array();
}
// Send the email
// TODO: The email notifications really need to be moved to a later point in edit_entry_handler.php
// TODO: so that they are outside the transaction block. There are two reasons for this. The first is
// TODO: that if one of the bookings fails for some reason the transaction will be rolled back, but
// TODO: the email notifications will already have been sent. The second is that they are inside a
// TODO: lock and so if the email notifications take a while, it will keep the lock longer than
// TODO: necessary. (This isn't as much of a problem as it used to be now that notify_ny_email()
// TODO: only adds a message to a queue, and the actual sending of the email is performed on shutdown,
// TODO: but can still be a problem if, for example, email addresses have to be looked up on a remote
// TODO: server, eg an LDAP server, and the query takes a while.)
notify_by_email($booking, $mail_previous, $is_repeat_table, 'book', $result['start_times']);
}
}
} // end foreach $bookings
$result['new_details'] = $new_details;
$result['slots'] = intval(($common['end_time'] - $common['start_time'])/$resolution);
return $result;
}
function get_bookings_in_series(?int $repeat_id) : array
{
$bookings = array();
if (isset($repeat_id))
{
$sql = "SELECT *
FROM " . _tbl('entry') . "
WHERE repeat_id = ?";
$res = db()->query($sql, array($repeat_id));
while (false !== ($row = $res->next_row_keyed()))
{
// Cast the columns to their correct types
row_cast_columns($row, 'entry');
// Unpack the status bits
unpack_status($row);
$bookings[] = $row;
}
}
return $bookings;
}