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; }