Files
itflow/functions/calendar.php
johnnyq 3ec52974db Calendar modernization: calendar page rebuilt to load only the dates on screen with show or hide toggles for each calendar and for built-in sources (scheduled tickets, project start and due dates, service reviews, recurring tickets, renewals, invoices due), a filter for one agent, quick looks on click, drag to reschedule tickets and events that do not repeat, and a To schedule panel for dragging open unscheduled tickets onto the calendar; events get a shared add/edit form with repeats every N days/weeks/months/years with an optional end date and single skipped occurrences, a client site, location or meeting link, agents, client contacts and guest emails as attendees, iCalendar invitations with updates and cancellations sent to invited attendees, an event quick look with map and join links, .ics download and resend; each agent gets a personal subscription link for their schedule (their own or the whole team, busy only option, respects role and client access, stops when the account is disabled) with setup help for Google, Outlook, Apple, Nextcloud and Thunderbird; shared calendar feeds and invites now publish timed events with TZID and VTIMEZONE so repeating events keep their time across DST, with INTERVAL, UNTIL and EXDATE support; managing calendars is limited to admins; ticket schedule logic moved into functions/ticket.php and shared with the calendar, the schedule dialog shows the assigned agent's day, and ticket visit calendar files are rebuilt without zapcal as one-hour published events; mail queue sends invitations and cancellations as a text/calendar part and other calendar files as invite.ics; dashboard schedule shows only events the agent is on or added, client overview Coming up and client sidebar count use upcoming events, sites and contacts get New event shortcuts, demo data uses repeating events and attendees; database update 2.8.8 adds event repeat interval, until, skip, sequence and created by, attendee user and invited at, user_calendar_feeds, and widens email_queue.email_cal_str to text
2026-10-15 19:17:17 -04:00

1394 lines
66 KiB
PHP

<?php
/*
* Calendar - events, the schedule the rest of ITFlow puts on the calendar, and
* the iCalendar (RFC 5545) the subscription links and invitations are made of.
*
* An event lives in one of the calendars (Onsite visits, Maintenance windows,
* Internal...), may be for a client and one of their sites, and has attendees:
* agents, the client's contacts, or anyone by email. Attendees can be sent an
* invitation; when the event changes or is deleted they can be sent the update
* or the cancellation, which their calendar applies to the invitation it
* already has (same UID, higher SEQUENCE).
*
* A repeat is every N days, weeks, months or years, optionally until a date,
* and single occurrences can be skipped. It is stored once and expanded here -
* in the server's timezone, so a 9:00 meeting stays at 9:00 across DST - and
* published to subscribers as an RRULE with the timezone attached.
*
* Besides events, the calendar shows what other modules have dated: scheduled
* tickets, project starts and due dates, service reviews, recurring tickets,
* renewals and invoices falling due (calendarSources()). Each agent can also
* subscribe to their own schedule - their scheduled tickets, the events they
* are on, the projects they manage - from a phone, Nextcloud or Google.
*
* Stored datetimes are wall clock in the instance timezone (config_timezone).
*/
/** How a repeat is worded: [stored value => [RRULE FREQ, one, many]]. */
const CALENDAR_REPEATS = [
'Day' => ['DAILY', 'day', 'days'],
'Week' => ['WEEKLY', 'week', 'weeks'],
'Month' => ['MONTHLY', 'month', 'months'],
'Year' => ['YEARLY', 'year', 'years'],
];
/** How long a scheduled ticket is taken to last on the calendar, in feeds and in its invitation - tickets store only the start. */
const CALENDAR_TICKET_MINUTES = 60;
/** How far a subscription link reaches, back and forward. Repeating events are always included. */
const CALENDAR_FEED_MONTHS_PAST = 12;
const CALENDAR_FEED_MONTHS_FUTURE = 24;
/*
* ---------- who may do what ----------
*/
/** Calendars themselves - adding, renaming, deleting and publishing one - are an admin's to manage. */
function calendarCanManage() {
global $session_is_admin;
return !empty($session_is_admin);
}
/** May this agent move scheduled tickets on the calendar? The same as rescheduling one on the ticket. */
function calendarCanScheduleTickets() {
return lookupUserPermission('module_support') >= 2;
}
/** Every calendar, by name - [id => row]. */
function calendarList() {
global $mysqli;
$calendars = [];
$sql = mysqli_query($mysqli, "SELECT calendar_id, calendar_name, calendar_color, calendar_feed_key, calendar_feed_busy_only, calendar_feed_created_at, calendar_feed_accessed_at
FROM calendars WHERE calendar_archived_at IS NULL ORDER BY calendar_name ASC");
while ($row = mysqli_fetch_assoc($sql)) {
$calendars[intval($row['calendar_id'])] = $row;
}
return $calendars;
}
/**
* A calendar's colour as a safe CSS hex value. The picker stores hex; older
* calendars may hold a colour name, drawn in the matching shade of the app's
* palette rather than the browser's pure blue / green.
*/
function calendarColor($color, $fallback = '#6c757d') {
$color = strtolower(trim((string) $color));
$named = [
'blue' => '#0d6efd', 'navy' => '#1d3f8f', 'indigo' => '#6610f2', 'purple' => '#6f42c1', 'pink' => '#d63384',
'red' => '#dc3545', 'orange' => '#fd7e14', 'yellow' => '#e0a800', 'gold' => '#c9a227', 'green' => '#198754',
'teal' => '#20c997', 'cyan' => '#0dcaf0', 'lightblue' => '#5dade2', 'brown' => '#8b5a2b', 'black' => '#343a40',
'gray' => '#6c757d', 'grey' => '#6c757d', 'slategray' => '#64748b', 'maroon' => '#8b1e3f', 'olive' => '#6b8e23',
];
if (isset($named[$color])) {
return $named[$color];
}
return preg_match('/^#([0-9a-f]{3}|[0-9a-f]{6})$/', $color) ? $color : $fallback;
}
/*
* ---------- events ----------
*/
/** One event, with its calendar, client, site and organiser - raw, or null. */
function calendarEventRow($event_id) {
global $mysqli;
$event_id = intval($event_id);
if (!$event_id) {
return null;
}
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT calendar_events.*, calendar_name, calendar_color, client_name,
location_name, location_address, location_city, location_state, location_zip, location_country, location_phone, location_phone_country_code,
user_name AS organizer_name, user_email AS organizer_email, user_avatar AS organizer_avatar
FROM calendar_events
LEFT JOIN calendars ON calendar_id = event_calendar_id
LEFT JOIN clients ON client_id = event_client_id AND event_client_id > 0
LEFT JOIN locations ON location_id = event_location_id AND event_location_id > 0
LEFT JOIN users ON user_id = event_created_by AND event_created_by > 0
WHERE event_id = $event_id AND event_archived_at IS NULL LIMIT 1"));
return $row ?: null;
}
/**
* For post handlers: the event a request names, after the client check (an
* event with no client is anyone's) - or back with an error if it is gone.
* Sets the global $client_id.
*/
function calendarEventForRequest($event_id) {
global $client_id;
$row = calendarEventRow($event_id);
if (!$row) {
flashAlert('That event no longer exists.', 'error');
redirect();
}
$client_id = intval($row['event_client_id']);
if ($client_id) {
enforceClientAccess();
}
return $row;
}
/** For modals: the same, or the modal's error. */
function calendarEventForModal($event_id) {
global $client_id;
$row = calendarEventRow($event_id);
if (!$row) {
echo json_encode(['error' => 'That event no longer exists.']);
exit;
}
$client_id = intval($row['event_client_id']);
if ($client_id && !hasClientAccess($client_id)) {
echo json_encode(['error' => 'You do not have access to that client.']);
exit;
}
return $row;
}
/**
* When an event happens, in words: "Thu, Oct 15, 2026, 10:00 AM - 12:30 PM",
* "Mon, Oct 19 - Wed, Oct 21, 2026 (all day)". $start/$end default to the
* event's own (pass an occurrence's to word that one).
*/
function calendarWhenText($row, $start = null, $end = null) {
$start = $start ?: $row['event_start'];
$end = $end ?: ($row['event_end'] ?: $start);
$s = strtotime($start);
$e = strtotime($end) ?: $s;
if (!empty($row['event_all_day'])) {
if (date('Y-m-d', $s) >= date('Y-m-d', $e)) {
return date('D, M j, Y', $s) . ' (all day)';
}
return date('D, M j', $s) . ' - ' . date('D, M j, Y', $e) . ' (all day)';
}
if (date('Y-m-d', $s) === date('Y-m-d', $e)) {
return date('D, M j, Y, g:i A', $s) . ($e > $s ? ' - ' . date('g:i A', $e) : '');
}
return date('D, M j, g:i A', $s) . ' - ' . date('D, M j, Y, g:i A', $e);
}
/** Where an event is as one line - the site's name and address - or the location typed in. */
function calendarWhereText($row) {
if (!empty($row['location_name'])) {
$address = implode(', ', array_filter([$row['location_address'] ?? '', $row['location_city'] ?? '', trim(($row['location_state'] ?? '') . ' ' . ($row['location_zip'] ?? ''))]));
return trim($row['location_name'] . ($address !== '' ? " - $address" : ''));
}
return trim((string) ($row['event_location'] ?? ''));
}
/** A link to the place on a map, or '' when there is nowhere to look up. */
function calendarMapUrl($row) {
$where = !empty($row['location_name'])
? implode(', ', array_filter([$row['location_address'] ?? '', $row['location_city'] ?? '', $row['location_state'] ?? '', $row['location_zip'] ?? '']))
: trim((string) ($row['event_location'] ?? ''));
// Something that reads as an address - a number in it, or a comma - not "Office" or a meeting link
if ($where === '' || preg_match('#^(https?://|remote|teams|zoom|online|phone|call)#i', $where) || !preg_match('/\d|,/', $where)) {
return '';
}
return 'https://www.google.com/maps/search/?api=1&query=' . rawurlencode($where);
}
/** A meeting link typed into the location - Teams, Zoom, Meet or any web address - or ''. */
function calendarMeetingUrl($row) {
if (preg_match('#https?://[^\s<>"\']+#i', (string) ($row['event_location'] ?? '') . ' ' . (string) ($row['event_description'] ?? ''), $match)) {
return rtrim($match[0], '.,);');
}
return '';
}
/** Does it repeat? */
function calendarEventRepeats($row) {
return isset(CALENDAR_REPEATS[(string) ($row['event_repeat'] ?? '')]);
}
/** "Every week", "Every 2 weeks until Mar 3, 2027", or '' for an event that does not repeat. */
function calendarRepeatText($row) {
if (!calendarEventRepeats($row)) {
return '';
}
$interval = max(1, intval($row['event_repeat_interval'] ?? 1));
[, $one, $many] = CALENDAR_REPEATS[(string) $row['event_repeat']];
$text = $interval === 1 ? "Every $one" : "Every $interval $many";
if (!empty($row['event_repeat_until'])) {
$text .= ' until ' . date('M j, Y', strtotime($row['event_repeat_until']));
}
return $text;
}
/** The dates of occurrences that have been skipped - ['Y-m-d', ...]. */
function calendarSkippedDates($row) {
$skip = trim((string) ($row['event_repeat_skip'] ?? ''));
if ($skip === '') {
return [];
}
return array_values(array_unique(array_filter(array_map('trim', explode(',', $skip)), function ($date) {
return (bool) preg_match('/^\d{4}-\d{2}-\d{2}$/', $date);
})));
}
/**
* Expands a repeating event into concrete occurrences within a window.
*
* Every N days, weeks, months or years from the start, until event_repeat_until
* (inclusive) when it has one, leaving out skipped dates. Offsets are worked out
* from the original start in the server's timezone rather than by stepping a
* running date, so a long series cannot drift and a 9:00 meeting stays at 9:00
* either side of a DST change. Monthly on the 31st and yearly on Feb 29 skip the
* months without that day, as RFC 5545 does, rather than rolling into the next.
*
* An occurrence still under way at the start of the window is included. The
* bundled FullCalendar build has no rrule plugin, so the page gets the
* occurrences from here; subscription feeds publish the RRULE instead.
* Returns [['start' => ..., 'end' => ...], ...]; an event that does not repeat
* comes back as itself.
*/
function expandRecurringEvent(array $event, $window_start, $window_end, $limit = 750) {
$start = $event['event_start'];
$repeat = (string) ($event['event_repeat'] ?? '');
if (empty($start) || !isset(CALENDAR_REPEATS[$repeat])) {
return [['start' => $start, 'end' => $event['event_end']]];
}
try {
$base = new DateTime($start);
$from = (new DateTime($window_start))->format('Y-m-d H:i:s');
$to = new DateTime($window_end);
} catch (Exception $e) {
return [['start' => $start, 'end' => $event['event_end']]];
}
// Each occurrence keeps the original's length
$duration = 0;
if (!empty($event['event_end']) && $event['event_end'] !== '0000-00-00 00:00:00') {
$duration = max(0, strtotime($event['event_end']) - strtotime($start));
}
$interval = max(1, intval($event['event_repeat_interval'] ?? 1));
$until = !empty($event['event_repeat_until']) ? substr($event['event_repeat_until'], 0, 10) . ' 23:59:59' : null;
$skip = array_flip(calendarSkippedDates($event));
$step = ['Day' => 'day', 'Week' => 'week', 'Month' => 'month', 'Year' => 'year'][$repeat];
$base_day = (int) $base->format('j');
$occurrences = [];
for ($i = 0; count($occurrences) < $limit && $i < 50000; $i++) {
$candidate = clone $base;
if ($i > 0) {
$candidate->modify('+' . ($i * $interval) . " $step");
if (($step === 'month' || $step === 'year') && (int) $candidate->format('j') !== $base_day) {
if ($candidate > $to) {
break;
}
continue;
}
}
$occurrence_start = $candidate->format('Y-m-d H:i:s');
if ($candidate > $to || ($until !== null && $occurrence_start > $until)) {
break;
}
$occurrence_end = null;
if ($duration > 0) {
$end_dt = clone $candidate;
$end_dt->modify("+$duration second");
$occurrence_end = $end_dt->format('Y-m-d H:i:s');
}
if (($occurrence_end ?: $occurrence_start) < $from || isset($skip[$candidate->format('Y-m-d')])) {
continue;
}
$occurrences[] = ['start' => $occurrence_start, 'end' => $occurrence_end];
}
return $occurrences;
}
/**
* The people on an event: agents, the client's contacts and anyone else by
* email - each with a kind, a name, an email and when they were last sent
* the invitation.
*/
function calendarEventAttendees($event_id) {
global $mysqli;
$event_id = intval($event_id);
$sql = mysqli_query($mysqli, "SELECT attendee_id, attendee_name, attendee_email, attendee_invited_at, attendee_user_id, attendee_contact_id,
user_name, user_email, user_avatar, contact_name, contact_email, contact_photo, contact_title, contact_client_id,
contact_phone, contact_phone_country_code, contact_extension, contact_mobile, contact_mobile_country_code
FROM calendar_event_attendees
LEFT JOIN users ON user_id = attendee_user_id AND attendee_user_id > 0
LEFT JOIN contacts ON contact_id = attendee_contact_id AND attendee_contact_id > 0
WHERE attendee_event_id = $event_id AND attendee_archived_at IS NULL
ORDER BY attendee_user_id = 0, attendee_contact_id = 0, COALESCE(user_name, contact_name, attendee_name, attendee_email)");
$rows = [];
while ($row = mysqli_fetch_assoc($sql)) {
$row['kind'] = intval($row['attendee_user_id']) ? 'agent' : (intval($row['attendee_contact_id']) ? 'contact' : 'guest');
// A contact or agent since deleted leaves a row with nobody behind it
if (($row['kind'] === 'agent' && $row['user_name'] === null) || ($row['kind'] === 'contact' && $row['contact_name'] === null)) {
continue;
}
$row['name'] = $row['user_name'] ?: ($row['contact_name'] ?: ($row['attendee_name'] ?: $row['attendee_email']));
$row['email'] = $row['user_email'] ?: ($row['contact_email'] ?: $row['attendee_email']);
$rows[] = $row;
}
return $rows;
}
/**
* Set who is on an event: agents (active ones), contacts (the event's client's
* own) and other email addresses. Anyone already on it keeps their row - and
* when they were invited; anyone taken off is removed. Returns
* ['new' => attendee ids added, 'removed' => the rows taken off].
*/
function calendarSetAttendees($event_id, $client_id, array $user_ids, array $contact_ids, array $emails) {
global $mysqli;
$event_id = intval($event_id);
$client_id = intval($client_id);
$user_ids = array_values(array_unique(array_filter(array_map('intval', $user_ids))));
if ($user_ids) {
$valid = [];
$sql = mysqli_query($mysqli, "SELECT user_id FROM users WHERE user_id IN (" . implode(',', $user_ids) . ") AND user_type = 1 AND user_archived_at IS NULL");
while ($row = mysqli_fetch_row($sql)) {
$valid[] = intval($row[0]);
}
$user_ids = $valid;
}
$contact_ids = array_values(array_unique(array_filter(array_map('intval', $contact_ids))));
if ($contact_ids && $client_id) {
$valid = [];
$sql = mysqli_query($mysqli, "SELECT contact_id FROM contacts WHERE contact_id IN (" . implode(',', $contact_ids) . ") AND contact_client_id = $client_id AND contact_archived_at IS NULL");
while ($row = mysqli_fetch_row($sql)) {
$valid[] = intval($row[0]);
}
$contact_ids = $valid;
} else {
$contact_ids = [];
}
$clean_emails = [];
foreach ($emails as $email) {
$email = strtolower(trim((string) $email));
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) {
$clean_emails[$email] = true;
}
}
$emails = array_slice(array_keys($clean_emails), 0, 50);
// What is there now
$existing = ['user' => [], 'contact' => [], 'email' => []];
$sql = mysqli_query($mysqli, "SELECT attendee_id, attendee_user_id, attendee_contact_id, LOWER(attendee_email) AS email FROM calendar_event_attendees WHERE attendee_event_id = $event_id");
while ($row = mysqli_fetch_assoc($sql)) {
if (intval($row['attendee_user_id'])) {
$existing['user'][intval($row['attendee_user_id'])] = intval($row['attendee_id']);
} elseif (intval($row['attendee_contact_id'])) {
$existing['contact'][intval($row['attendee_contact_id'])] = intval($row['attendee_id']);
} else {
$existing['email'][(string) $row['email']] = intval($row['attendee_id']);
}
}
$keep = [];
$new = [];
foreach ($user_ids as $user_id) {
if (isset($existing['user'][$user_id])) {
$keep[] = $existing['user'][$user_id];
} else {
mysqli_query($mysqli, "INSERT INTO calendar_event_attendees SET attendee_user_id = $user_id, attendee_event_id = $event_id");
$keep[] = $new[] = mysqli_insert_id($mysqli);
}
}
foreach ($contact_ids as $contact_id) {
if (isset($existing['contact'][$contact_id])) {
$keep[] = $existing['contact'][$contact_id];
} else {
mysqli_query($mysqli, "INSERT INTO calendar_event_attendees SET attendee_contact_id = $contact_id, attendee_event_id = $event_id");
$keep[] = $new[] = mysqli_insert_id($mysqli);
}
}
foreach ($emails as $email) {
if (isset($existing['email'][$email])) {
$keep[] = $existing['email'][$email];
} else {
mysqli_query($mysqli, "INSERT INTO calendar_event_attendees SET attendee_email = '" . escapeSql($email) . "', attendee_event_id = $event_id");
$keep[] = $new[] = mysqli_insert_id($mysqli);
}
}
// Those taken off - kept for a moment, so the caller can tell them it is off
$removed = [];
$not_kept = $keep ? " AND attendee_id NOT IN (" . implode(',', array_map('intval', $keep)) . ")" : '';
foreach (calendarEventAttendees($event_id) as $attendee) {
if (!in_array(intval($attendee['attendee_id']), $keep, true)) {
$removed[] = $attendee;
}
}
mysqli_query($mysqli, "DELETE FROM calendar_event_attendees WHERE attendee_event_id = $event_id$not_kept");
return ['new' => $new, 'removed' => $removed];
}
/**
* The fields of the add and edit forms, checked, as a SET clause - or '' with
* $error. Dates and times arrive separately and are recombined; an all-day
* event is pinned to midnight with event_end holding the LAST day it covers
* (FullCalendar and iCalendar treat an all-day end as exclusive, so those
* paths add the day). $client_id is the client (already access-checked) and
* location_id must be one of their sites.
*/
function calendarEventFormSet($post, $client_id, &$error = '') {
global $mysqli;
$title = trim((string) ($post['title'] ?? ''));
if ($title === '') {
$error = 'An event needs a title.';
return '';
}
$calendar_id = intval($post['calendar'] ?? 0);
if (!$calendar_id || !mysqli_num_rows(mysqli_query($mysqli, "SELECT 1 FROM calendars WHERE calendar_id = $calendar_id AND calendar_archived_at IS NULL"))) {
$error = 'Pick the calendar it goes on.';
return '';
}
$all_day = !empty($post['all_day']) ? 1 : 0;
$start_date = (string) ($post['start_date'] ?? '');
$end_date = !empty($post['end_date']) ? (string) $post['end_date'] : $start_date;
if (!calendarIsDate($start_date)) {
$error = 'Pick the day it starts.';
return '';
}
if (!calendarIsDate($end_date)) {
$end_date = $start_date;
}
if ($all_day) {
$start = "$start_date 00:00:00";
$end = "$end_date 00:00:00";
} else {
$start_time = preg_match('/^\d{2}:\d{2}$/', (string) ($post['start_time'] ?? '')) ? $post['start_time'] : '09:00';
$end_time = preg_match('/^\d{2}:\d{2}$/', (string) ($post['end_time'] ?? '')) ? $post['end_time'] : '';
$start = "$start_date $start_time:00";
$end = $end_time !== '' ? "$end_date $end_time:00" : date('Y-m-d H:i:s', strtotime($start) + 3600);
}
if ($end < $start) {
$error = 'It has to end after it starts.';
return '';
}
$repeat = isset(CALENDAR_REPEATS[$post['repeat'] ?? '']) ? $post['repeat'] : '';
$interval = $repeat ? max(1, min(99, intval($post['repeat_interval'] ?? 1))) : 1;
$until = $repeat ? trim((string) ($post['repeat_until'] ?? '')) : '';
if ($until !== '' && (!calendarIsDate($until) || $until < $start_date)) {
$until = '';
}
$location_id = 0;
if ($client_id && intval($post['location_id'] ?? 0)) {
$location_id = intval(assetClientOwnedId('location', $post['location_id'], $client_id));
}
return "event_title = '" . escapeSql(mb_substr($title, 0, 200)) . "',
event_location = '" . escapeSql(mb_substr(trim((string) ($post['location'] ?? '')), 0, 500)) . "',
event_description = '" . escapeSql(trim((string) ($post['description'] ?? ''))) . "',
event_start = '$start', event_end = '$end', event_all_day = $all_day,
event_repeat = '$repeat', event_repeat_interval = $interval, event_repeat_until = " . ($until !== '' ? "'$until'" : 'NULL') . ",
event_calendar_id = $calendar_id, event_client_id = " . intval($client_id) . ", event_location_id = $location_id";
}
/** Is it a real Y-m-d date? */
function calendarIsDate($value) {
$date = DateTime::createFromFormat('!Y-m-d', (string) $value);
return $date && $date->format('Y-m-d') === $value;
}
/*
* ---------- what the rest of ITFlow puts on the calendar ----------
*/
/**
* The schedule other modules contribute, each a source that can be shown or
* hidden: [key => label, icon, colour, the module it needs, shown by default].
* Only the ones this agent may see.
*/
function calendarSources() {
$sources = [
'tickets' => ['label' => 'Scheduled tickets', 'icon' => 'fa-life-ring', 'color' => '#0d6efd', 'module' => 'module_support', 'default' => true,
'title' => 'Onsite and remote work booked on tickets'],
'projects' => ['label' => 'Projects', 'icon' => 'fa-project-diagram', 'color' => '#0f9d8a', 'module' => 'module_support', 'default' => true,
'title' => 'When open projects start and are due'],
'reviews' => ['label' => 'Service reviews', 'icon' => 'fa-clipboard-check', 'color' => '#64748b', 'module' => 'module_support', 'default' => true,
'title' => 'When each service is next due for review'],
'recurring' => ['label' => 'Recurring tickets', 'icon' => 'fa-redo-alt', 'color' => '#7c3aed', 'module' => 'module_support', 'default' => false,
'title' => 'When each recurring ticket is next raised'],
'renewals' => ['label' => 'Renewals', 'icon' => 'fa-hourglass-half', 'color' => '#d97706', 'module' => 'module_support', 'default' => false,
'title' => 'Domains, certificates, licenses and warranties expiring'],
'invoices' => ['label' => 'Invoices due', 'icon' => 'fa-file-invoice-dollar', 'color' => '#15803d', 'module' => 'module_sales', 'default' => false,
'title' => 'Unpaid invoices on the day they fall due'],
];
return array_filter($sources, function ($source) {
return lookupUserPermission($source['module']) >= 1;
});
}
/** A scheduled ticket's title on the calendar: "TCK-12 Onsite - Client: subject". */
function calendarTicketTitle($row, $with_client = true) {
return ticketReference($row) . ' ' . (intval($row['ticket_onsite']) ? 'Onsite' : 'Remote') . ' - '
. ($with_client && !empty($row['client_name']) ? $row['client_name'] . ': ' : '') . $row['ticket_subject'];
}
/**
* The items one built-in source has between two datetimes, ready for
* FullCalendar. $opts: client_id (one client, or 0 for every client this
* agent can reach) and user_id (only that agent's; 0 for everyone's).
*/
function calendarSourceItems($key, $from, $to, array $opts = []) {
global $mysqli;
$sources = calendarSources();
if (!isset($sources[$key])) {
return [];
}
$source = $sources[$key];
$client_id = intval($opts['client_id'] ?? 0);
$user_id = intval($opts['user_id'] ?? 0);
$from = escapeSql($from);
$to = escapeSql($to);
$from_date = substr($from, 0, 10);
$to_date = substr($to, 0, 10);
$items = [];
$base = ['color' => $source['color'], 'editable' => false];
$client_label = function ($row) use ($client_id) {
return !$client_id && !empty($row['client_name']) ? $row['client_name'] . ': ' : '';
};
if ($key === 'tickets') {
$can_move = calendarCanScheduleTickets();
$sql = mysqli_query($mysqli, "SELECT tickets.ticket_id, ticket_prefix, ticket_number, ticket_subject, ticket_schedule, ticket_onsite, ticket_client_id,
ticket_resolved_at, ticket_closed_at, ticket_assigned_to, client_name, user_name, ticket_status_name
FROM tickets
LEFT JOIN clients ON client_id = ticket_client_id
LEFT JOIN users ON user_id = ticket_assigned_to
LEFT JOIN ticket_statuses ON ticket_status_id = ticket_status
WHERE ticket_schedule BETWEEN '$from' AND '$to' AND ticket_archived_at IS NULL
" . ($client_id ? "AND ticket_client_id = $client_id" : '') . "
" . ($user_id ? "AND ticket_assigned_to = $user_id" : '') . "
" . clientScopeSql('ticket_client_id'));
while ($row = mysqli_fetch_assoc($sql)) {
$open = ticketIsOpen($row);
$items[] = [
'id' => 'ticket-' . intval($row['ticket_id']),
'title' => calendarTicketTitle($row, !$client_id),
'start' => $row['ticket_schedule'],
'end' => date('Y-m-d H:i:s', strtotime($row['ticket_schedule']) + CALENDAR_TICKET_MINUTES * 60),
'allDay' => false,
'editable' => $can_move && $open,
'durationEditable' => false,
'className' => $open ? '' : 'itflow-cal-done',
'extendedProps' => ['kind' => 'ticket', 'ticket_id' => intval($row['ticket_id']), 'client_id' => intval($row['ticket_client_id']),
'icon' => intval($row['ticket_onsite']) ? 'fa-car' : 'fa-headset',
'modal' => 'modals/ticket/ticket.php?id=' . intval($row['ticket_id']),
'detail' => implode(' · ', array_filter([$row['user_name'] ?: 'Unassigned', $row['ticket_status_name']]))],
] + $base;
}
} elseif ($key === 'projects') {
$sql = mysqli_query($mysqli, "SELECT project_id, project_prefix, project_number, project_name, project_start, project_due, project_client_id, client_name
FROM projects LEFT JOIN clients ON client_id = project_client_id
WHERE " . PROJECT_OPEN_SQL . " AND (project_start BETWEEN '$from_date' AND '$to_date' OR project_due BETWEEN '$from_date' AND '$to_date')
" . ($client_id ? "AND project_client_id = $client_id" : '') . "
" . ($user_id ? "AND project_manager = $user_id" : '') . "
" . clientScopeSql('project_client_id'));
while ($row = mysqli_fetch_assoc($sql)) {
foreach (['project_start' => ['starts', 'fa-play'], 'project_due' => ['due', 'fa-flag-checkered']] as $column => [$what, $icon]) {
if ($row[$column] && $row[$column] >= $from_date && $row[$column] <= $to_date) {
$items[] = [
'id' => "project-$what-" . intval($row['project_id']),
'title' => projectReference($row) . " $what - " . $client_label($row) . $row['project_name'],
'start' => $row[$column], 'allDay' => true,
'extendedProps' => ['kind' => 'project', 'icon' => $icon, 'modal' => 'modals/project/project.php?id=' . intval($row['project_id']),
'url' => projectUrl($row['project_id'], $row['project_client_id'])],
] + $base;
}
}
}
} elseif ($key === 'reviews') {
$sql = mysqli_query($mysqli, "SELECT service_id, service_name, service_review_due, service_client_id, client_name FROM services
JOIN clients ON client_id = service_client_id
WHERE service_archived_at IS NULL AND client_archived_at IS NULL AND service_review_due BETWEEN '$from_date' AND '$to_date'
" . ($client_id ? "AND service_client_id = $client_id" : '') . "
" . clientScopeSql('service_client_id'));
while ($row = mysqli_fetch_assoc($sql)) {
$items[] = [
'id' => 'review-' . intval($row['service_id']),
'title' => 'Review - ' . $client_label($row) . $row['service_name'],
'start' => $row['service_review_due'], 'allDay' => true,
'extendedProps' => ['kind' => 'review', 'icon' => 'fa-clipboard-check', 'modal' => 'modals/service/service.php?id=' . intval($row['service_id']),
'url' => serviceUrl($row['service_id'], $row['service_client_id'])],
] + $base;
}
} elseif ($key === 'recurring') {
$sql = mysqli_query($mysqli, "SELECT recurring_ticket_id, recurring_ticket_subject, recurring_ticket_frequency, recurring_ticket_next_run, recurring_ticket_client_id, client_name
FROM recurring_tickets LEFT JOIN clients ON client_id = recurring_ticket_client_id
WHERE recurring_ticket_next_run BETWEEN '$from_date' AND '$to_date' AND recurring_ticket_paused_at IS NULL
" . ($client_id ? "AND recurring_ticket_client_id = $client_id" : '') . "
" . ($user_id ? "AND recurring_ticket_assigned_to = $user_id" : '') . "
" . clientScopeSql('recurring_ticket_client_id'));
while ($row = mysqli_fetch_assoc($sql)) {
$row_client = intval($row['recurring_ticket_client_id']);
$items[] = [
'id' => 'recurring-' . intval($row['recurring_ticket_id']),
'title' => 'Raised - ' . $client_label($row) . $row['recurring_ticket_subject'],
'start' => $row['recurring_ticket_next_run'], 'allDay' => true,
'extendedProps' => ['kind' => 'recurring', 'icon' => 'fa-redo-alt', 'detail' => $row['recurring_ticket_frequency'],
'url' => 'recurring_tickets.php' . ($row_client ? "?client_id=$row_client" : '')],
] + $base;
}
} elseif ($key === 'renewals') {
$queries = [
'domain' => ["SELECT domain_id AS id, domain_name AS name, domain_expire AS d, client_name FROM domains LEFT JOIN clients ON client_id = domain_client_id
WHERE domain_archived_at IS NULL AND domain_expire BETWEEN '$from_date' AND '$to_date'" . ($client_id ? " AND domain_client_id = $client_id" : '') . clientScopeSql('domain_client_id'),
'Domain expires', 'fa-globe', 'modals/domain/domain.php?id='],
'certificate' => ["SELECT certificate_id AS id, certificate_name AS name, certificate_expire AS d, client_name FROM certificates LEFT JOIN clients ON client_id = certificate_client_id
WHERE certificate_archived_at IS NULL AND certificate_expire BETWEEN '$from_date' AND '$to_date'" . ($client_id ? " AND certificate_client_id = $client_id" : '') . clientScopeSql('certificate_client_id'),
'Certificate expires', 'fa-lock', 'modals/certificate/certificate.php?id='],
'software' => ["SELECT software_id AS id, software_name AS name, software_expire AS d, client_name FROM software LEFT JOIN clients ON client_id = software_client_id
WHERE software_archived_at IS NULL AND software_expire BETWEEN '$from_date' AND '$to_date'" . ($client_id ? " AND software_client_id = $client_id" : '') . clientScopeSql('software_client_id'),
'License renews', 'fa-cube', 'modals/software/software.php?id='],
'warranty' => ["SELECT asset_id AS id, asset_name AS name, asset_warranty_expire AS d, client_name FROM assets LEFT JOIN clients ON client_id = asset_client_id
WHERE asset_archived_at IS NULL AND asset_warranty_expire BETWEEN '$from_date' AND '$to_date'" . ($client_id ? " AND asset_client_id = $client_id" : '') . clientScopeSql('asset_client_id'),
'Warranty ends', 'fa-shield-alt', 'modals/asset/asset.php?id='],
];
foreach ($queries as $kind => [$query, $what, $icon, $modal]) {
$sql = mysqli_query($mysqli, $query);
while ($row = mysqli_fetch_assoc($sql)) {
$items[] = [
'id' => "renewal-$kind-" . intval($row['id']),
'title' => "$what - " . $client_label($row) . $row['name'],
'start' => $row['d'], 'allDay' => true,
'extendedProps' => ['kind' => 'renewal', 'icon' => $icon, 'modal' => $modal . intval($row['id'])],
] + $base;
}
}
} elseif ($key === 'invoices') {
$sql = mysqli_query($mysqli, "SELECT invoice_id, invoice_prefix, invoice_number, invoice_due, invoice_client_id, client_name
FROM invoices LEFT JOIN clients ON client_id = invoice_client_id
WHERE invoice_status IN ('Sent', 'Viewed', 'Partial') AND invoice_archived_at IS NULL AND invoice_due BETWEEN '$from_date' AND '$to_date'
" . ($client_id ? "AND invoice_client_id = $client_id" : '') . "
" . clientScopeSql('invoice_client_id'));
while ($row = mysqli_fetch_assoc($sql)) {
$items[] = [
'id' => 'invoice-' . intval($row['invoice_id']),
'title' => $row['invoice_prefix'] . $row['invoice_number'] . ' due - ' . $row['client_name'],
'start' => $row['invoice_due'], 'allDay' => true,
'extendedProps' => ['kind' => 'invoice', 'icon' => 'fa-file-invoice-dollar',
'url' => 'invoice.php?client_id=' . intval($row['invoice_client_id']) . '&invoice_id=' . intval($row['invoice_id'])],
] + $base;
}
}
return $items;
}
/**
* The events on some calendars between two datetimes, repeats expanded, ready
* for FullCalendar. $opts: client_id (one client's, or every client this agent
* can reach plus the events with no client), user_id (only the events that
* agent is on or organised).
*/
function calendarEventItems(array $calendar_ids, $from, $to, array $opts = []) {
global $mysqli;
$calendar_ids = array_values(array_filter(array_map('intval', $calendar_ids)));
if (!$calendar_ids) {
return [];
}
$from = escapeSql($from);
$to = escapeSql($to);
$client_id = intval($opts['client_id'] ?? 0);
$user_id = intval($opts['user_id'] ?? 0);
$user_query = $user_id ? "AND (event_created_by = $user_id OR EXISTS (SELECT 1 FROM calendar_event_attendees a WHERE a.attendee_event_id = event_id AND a.attendee_user_id = $user_id))" : '';
$sql = mysqli_query($mysqli, "SELECT event_id, event_title, event_start, event_end, event_all_day, event_repeat, event_repeat_interval, event_repeat_until, event_repeat_skip,
event_location, event_client_id, event_calendar_id, calendar_color, client_name, location_name,
(SELECT COUNT(*) FROM calendar_event_attendees a WHERE a.attendee_event_id = event_id) AS attendee_count,
(SELECT COUNT(*) FROM calendar_event_attendees a WHERE a.attendee_event_id = event_id AND a.attendee_invited_at IS NOT NULL) AS invited_count
FROM calendar_events
JOIN calendars ON calendar_id = event_calendar_id AND calendar_archived_at IS NULL
LEFT JOIN clients ON client_id = event_client_id AND event_client_id > 0
LEFT JOIN locations ON location_id = event_location_id AND event_location_id > 0
WHERE event_calendar_id IN (" . implode(',', $calendar_ids) . ") AND event_archived_at IS NULL
AND event_start <= '$to' AND ((event_repeat IS NOT NULL AND event_repeat <> '') OR COALESCE(event_end, event_start) >= '$from')
" . ($client_id ? "AND event_client_id = $client_id" : '') . "
$user_query
" . clientScopeSql('event_client_id'));
$items = [];
while ($row = mysqli_fetch_assoc($sql)) {
$event_id = intval($row['event_id']);
$all_day = !empty($row['event_all_day']);
$repeats = calendarEventRepeats($row);
foreach (expandRecurringEvent($row, $from, $to) as $occurrence) {
$end = $occurrence['end'];
// event_end is the last day an all-day event covers; FullCalendar's all-day end is exclusive
if ($all_day) {
$end = date('Y-m-d', strtotime(($end ?: $occurrence['start']) . ' +1 day'));
}
$items[] = [
'id' => "event-$event_id-" . date('YmdHi', strtotime($occurrence['start'])),
'title' => $row['event_title'],
'start' => $all_day ? substr($occurrence['start'], 0, 10) : $occurrence['start'],
'end' => $end,
'allDay' => $all_day,
'color' => calendarColor($row['calendar_color']),
// A repeat is moved from its form, so a drag cannot shift the whole series by accident
'editable' => !$repeats,
'extendedProps' => [
'kind' => 'event', 'event_id' => $event_id, 'calendar_id' => intval($row['event_calendar_id']), 'occurrence' => substr($occurrence['start'], 0, 10),
'invited' => intval($row['invited_count']),
'icon' => $repeats ? 'fa-redo' : (intval($row['attendee_count']) ? 'fa-user-friends' : ''),
'modal' => "modals/calendar/calendar_event.php?id=$event_id&occurrence=" . substr($occurrence['start'], 0, 10),
'detail' => implode(' · ', array_filter([$client_id ? '' : ($row['client_name'] ?? ''), $row['location_name'] ?: $row['event_location']])),
],
];
}
}
return $items;
}
/*
* ---------- iCalendar ----------
*/
/*
* Escapes a value for an iCalendar TEXT property (RFC 5545 3.3.11)
* Backslashes are escaped first, otherwise the escapes added below get escaped again
*/
function icsEscapeText($text) {
$text = str_replace("\\", "\\\\", (string) $text);
$text = str_replace(["\r\n", "\r", "\n"], "\\n", $text);
return str_replace([";", ","], ["\\;", "\\,"], $text);
}
/** A parameter value (CN=...) - quoted when it has anything a parameter cannot carry bare. */
function icsParam($value) {
$value = str_replace(['"', "\r", "\n"], ["'", ' ', ' '], (string) $value);
return preg_match('/[;:,]/', $value) ? '"' . $value . '"' : $value;
}
/*
* Folds a content line to 75 octets per RFC 5545 3.1
* Continuation lines begin with a single space which counts toward the 75, so
* $buffer already carries it. Folding happens on character boundaries so a
* multi-byte UTF-8 sequence is never cut in half - splitting mid-sequence is
* what makes hand-rolled feeds fail to parse on non-ASCII event titles.
*/
function icsFoldLine($line) {
if (strlen($line) <= 75) {
return $line;
}
$chars = preg_split('//u', $line, -1, PREG_SPLIT_NO_EMPTY);
// Invalid UTF-8 - fold on byte boundaries rather than emit an over-long line
if ($chars === false) {
return rtrim(chunk_split($line, 74, "\r\n "), "\r\n ");
}
$folded = '';
$buffer = '';
foreach ($chars as $char) {
if (strlen($buffer) + strlen($char) > 75) {
$folded .= $buffer . "\r\n";
$buffer = ' ';
}
$buffer .= $char;
}
return $folded . $buffer;
}
/*
* Converts an ITFlow datetime to an iCalendar UTC DATE-TIME
* Stored datetimes are wall clock in the instance's configured timezone.
*/
function icsFormatUtc($datetime, $timezone) {
if (empty($datetime) || $datetime === '0000-00-00 00:00:00') {
return null;
}
try {
$dt = new DateTime($datetime, new DateTimeZone($timezone ?: 'UTC'));
$dt->setTimezone(new DateTimeZone('UTC'));
} catch (Exception $e) {
return null;
}
return $dt->format('Ymd\THis\Z');
}
/** An ITFlow datetime as a local iCalendar DATE-TIME, for a property carrying TZID. */
function icsFormatLocal($datetime) {
$timestamp = strtotime((string) $datetime);
return $timestamp === false ? null : date('Ymd\THis', $timestamp);
}
/*
* Converts an ITFlow datetime to an iCalendar DATE value (all-day events)
* No timezone conversion - a floating date must stay on the day it was entered
*/
function icsFormatDate($datetime, $offset_days = 0) {
if (empty($datetime) || $datetime === '0000-00-00 00:00:00') {
return null;
}
$timestamp = strtotime($datetime);
if ($timestamp === false) {
return null;
}
return date('Ymd', strtotime("$offset_days day", $timestamp));
}
/** Is this a timezone PHP knows, other than UTC? Timed events are then published in it, with its VTIMEZONE. */
function icsUsesTzid($timezone) {
if (empty($timezone) || in_array(strtoupper((string) $timezone), ['UTC', 'Z', 'GMT', 'ETC/UTC'], true)) {
return false;
}
try {
new DateTimeZone($timezone);
return true;
} catch (Exception $e) {
return false;
}
}
/** An offset in seconds as an iCalendar UTC-OFFSET: -0500, +0530. */
function icsOffset($seconds) {
$sign = $seconds < 0 ? '-' : '+';
$seconds = abs(intval($seconds));
return $sign . sprintf('%02d%02d', intdiv($seconds, 3600), intdiv($seconds % 3600, 60));
}
/**
* The lines of a VTIMEZONE for $timezone covering $from to $to (timestamps),
* built from PHP's own transition data: one STANDARD or DAYLIGHT observance
* per change, each starting at the local time the change happens. Clients
* that know the zone by name use their own rules; the rest have them here.
*/
function icsVtimezone($timezone, $from, $to) {
$tz = new DateTimeZone($timezone);
$transitions = $tz->getTransitions($from, $to) ?: [];
$lines = ['BEGIN:VTIMEZONE', 'TZID:' . $timezone];
if (!$transitions) {
$offset = $tz->getOffset(new DateTime('@' . $from));
return array_merge($lines, ['BEGIN:STANDARD', 'DTSTART:19700101T000000', 'TZOFFSETFROM:' . icsOffset($offset), 'TZOFFSETTO:' . icsOffset($offset), 'END:STANDARD', 'END:VTIMEZONE']);
}
$previous = $transitions[0]['offset'];
foreach ($transitions as $transition) {
$kind = $transition['isdst'] ? 'DAYLIGHT' : 'STANDARD';
$lines[] = "BEGIN:$kind";
// Local time as it read just before the change
$lines[] = 'DTSTART:' . gmdate('Ymd\THis', $transition['ts'] + $previous);
$lines[] = 'TZOFFSETFROM:' . icsOffset($previous);
$lines[] = 'TZOFFSETTO:' . icsOffset($transition['offset']);
if (preg_match('/^[A-Za-z]{2,6}$/', (string) $transition['abbr'])) {
$lines[] = 'TZNAME:' . $transition['abbr'];
}
$lines[] = "END:$kind";
$previous = $transition['offset'];
}
$lines[] = 'END:VTIMEZONE';
return $lines;
}
/*
* Maps ITFlow's event_repeat wording onto an RRULE: FREQ, then INTERVAL when
* more than one, then UNTIL. A timed event's UNTIL is the end of that day in
* UTC (RFC 5545 requires UTC when DTSTART carries a TZID); an all-day event's
* is the date itself.
*/
function icsRepeatToRrule($repeat, $interval = 1, $until = null, $all_day = false, $timezone = 'UTC') {
if (!isset(CALENDAR_REPEATS[(string) $repeat])) {
return null;
}
$rule = 'FREQ=' . CALENDAR_REPEATS[(string) $repeat][0];
if (intval($interval) > 1) {
$rule .= ';INTERVAL=' . intval($interval);
}
if (!empty($until)) {
$rule .= ';UNTIL=' . ($all_day ? date('Ymd', strtotime($until)) : icsFormatUtc(substr($until, 0, 10) . ' 23:59:59', $timezone));
}
return $rule;
}
/*
* True when an event looks like an all-day event
* Fallback for rows written before event_all_day existed (added in 2.5.9): a
* midnight start, and either no end or a midnight end.
*/
function icsEventIsAllDay($start, $end) {
if (empty($start) || date('H:i:s', strtotime($start)) !== '00:00:00') {
return false;
}
if (empty($end) || $end === '0000-00-00 00:00:00') {
return true;
}
return date('H:i:s', strtotime($end)) === '00:00:00';
}
/**
* The lines of one VEVENT. $item:
* uid, start, end, all_day, summary, description, location, url, stamp,
* created, sequence, status, categories, busy (title and details withheld),
* repeat, repeat_interval, repeat_until, skip (dates), organizer ([name,
* email]), attendees ([[name, email], ...]).
* Timed events carry TZID=$timezone, or are in UTC when $tzid is false.
*/
function icsEventLines(array $item, $timezone, $tzid) {
$all_day = !empty($item['all_day']);
$lines = ['BEGIN:VEVENT', 'UID:' . $item['uid']];
$stamp = !empty($item['stamp']) ? icsFormatUtc($item['stamp'], $timezone) : null;
$lines[] = 'DTSTAMP:' . ($stamp ?: gmdate('Ymd\THis\Z'));
$start = $item['start'];
$end = $item['end'] ?? null;
if ($all_day) {
$dtstart = icsFormatDate($start);
// The last day covered, plus one - DTEND is exclusive for DATE values
$dtend = icsFormatDate($end ?: $start, 1);
if (empty($dtend) || $dtend <= $dtstart) {
$dtend = icsFormatDate($start, 1);
}
$lines[] = "DTSTART;VALUE=DATE:$dtstart";
$lines[] = "DTEND;VALUE=DATE:$dtend";
} else {
if (empty($end) || $end === '0000-00-00 00:00:00' || $end <= $start) {
$end = date('Y-m-d H:i:s', strtotime($start) + 3600);
}
if ($tzid) {
$lines[] = "DTSTART;TZID=$timezone:" . icsFormatLocal($start);
$lines[] = "DTEND;TZID=$timezone:" . icsFormatLocal($end);
} else {
$lines[] = 'DTSTART:' . icsFormatUtc($start, $timezone);
$lines[] = 'DTEND:' . icsFormatUtc($end, $timezone);
}
}
$rrule = icsRepeatToRrule($item['repeat'] ?? '', $item['repeat_interval'] ?? 1, $item['repeat_until'] ?? null, $all_day, $timezone);
if ($rrule) {
$lines[] = "RRULE:$rrule";
foreach ($item['skip'] ?? [] as $date) {
if ($all_day) {
$lines[] = 'EXDATE;VALUE=DATE:' . date('Ymd', strtotime($date));
} else {
$at = $date . ' ' . date('H:i:s', strtotime($start));
$lines[] = $tzid ? "EXDATE;TZID=$timezone:" . icsFormatLocal($at) : 'EXDATE:' . icsFormatUtc($at, $timezone);
}
}
}
if (!empty($item['busy'])) {
// Titles, descriptions and locations all withheld - only that the time is taken
$lines[] = 'SUMMARY:Busy';
$lines[] = 'CLASS:PRIVATE';
} else {
$lines[] = 'SUMMARY:' . icsEscapeText($item['summary'] ?? '');
if (!empty($item['location'])) {
$lines[] = 'LOCATION:' . icsEscapeText($item['location']);
}
if (!empty($item['description'])) {
$lines[] = 'DESCRIPTION:' . icsEscapeText($item['description']);
}
if (!empty($item['url'])) {
$lines[] = 'URL:' . str_replace(["\r", "\n", ' '], '', $item['url']);
}
if (!empty($item['categories'])) {
$lines[] = 'CATEGORIES:' . icsEscapeText($item['categories']);
}
}
$lines[] = 'TRANSP:OPAQUE';
if (!empty($item['organizer'][1])) {
$lines[] = 'ORGANIZER;CN=' . icsParam($item['organizer'][0] ?: $item['organizer'][1]) . ':mailto:' . $item['organizer'][1];
}
foreach ($item['attendees'] ?? [] as [$name, $email]) {
$lines[] = 'ATTENDEE;CN=' . icsParam($name ?: $email) . ';ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=FALSE:mailto:' . $email;
}
if (isset($item['sequence'])) {
$lines[] = 'SEQUENCE:' . intval($item['sequence']);
}
if (!empty($item['status'])) {
$lines[] = 'STATUS:' . $item['status'];
}
if (!empty($item['created'])) {
$created = icsFormatUtc($item['created'], $timezone);
if ($created) {
$lines[] = "CREATED:$created";
}
}
if ($stamp) {
$lines[] = "LAST-MODIFIED:$stamp";
}
$lines[] = 'END:VEVENT';
return $lines;
}
/**
* A whole VCALENDAR - a subscription feed (METHOD:PUBLISH) or an invitation
* (REQUEST / CANCEL) - with CRLF line endings, folded, ready to send.
* $calendar: name, color, method, invite (no feed headers).
*/
function icsCalendar(array $calendar, array $items, $timezone) {
$tzid = icsUsesTzid($timezone);
$lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//ITFlow//ITFlow Calendar//EN',
'CALSCALE:GREGORIAN',
'METHOD:' . ($calendar['method'] ?? 'PUBLISH'),
];
if (empty($calendar['invite'])) {
// X-WR-CALNAME is non-standard and clients render its value literally, so a
// TEXT-escaped comma would show as a visible backslash in Google's sidebar.
// Strip only what would break the line structure.
$lines[] = 'X-WR-CALNAME:' . str_replace(["\r\n", "\r", "\n"], ' ', (string) ($calendar['name'] ?? 'ITFlow'));
$lines[] = 'X-WR-TIMEZONE:' . $timezone;
// Nextcloud reads these, but only during a refresh and only when no rate is
// stored for the subscription yet - its first refresh is still a day out
// unless the admin lowers calendarSubscriptionRefreshRate. Google ignores them.
$lines[] = 'REFRESH-INTERVAL;VALUE=DURATION:PT15M';
$lines[] = 'X-PUBLISHED-TTL:PT15M';
if (!empty($calendar['color']) && preg_match('/^#[0-9a-f]{6}$/i', $calendar['color'])) {
$lines[] = 'X-APPLE-CALENDAR-COLOR:' . $calendar['color'];
}
}
// One VTIMEZONE covering every timed item, when the zone is published by name
$timed = array_filter($items, function ($item) {
return empty($item['all_day']);
});
if ($tzid && $timed) {
$starts = array_map(function ($item) {
return strtotime($item['start']);
}, $timed);
$repeating = (bool) array_filter($timed, function ($item) {
return !empty($item['repeat']);
});
$from = min($starts) - 86400 * 366;
// A repeat runs on - cover the years a subscriber will look ahead
$to = max(max($starts), time()) + 86400 * 366 * ($repeating ? 5 : 1);
$lines = array_merge($lines, icsVtimezone($timezone, $from, $to));
}
foreach ($items as $item) {
$lines = array_merge($lines, icsEventLines($item, $timezone, $tzid));
}
$lines[] = 'END:VCALENDAR';
$output = '';
foreach ($lines as $line) {
$output .= icsFoldLine($line) . "\r\n";
}
return $output;
}
/** The host every UID and link is built from - config_base_url, without a scheme. */
function calendarHost() {
global $config_base_url;
return rtrim(preg_replace('#^[a-z]+://#i', '', (string) $config_base_url), '/') ?: 'itflow.local';
}
/** An event as an iCalendar item. $busy withholds the title and details. */
function calendarEventIcsItem(array $event, $busy = false) {
$host = calendarHost();
$event_id = intval($event['event_id']);
$all_day = isset($event['event_all_day']) ? !empty($event['event_all_day']) : icsEventIsAllDay($event['event_start'], $event['event_end']);
return [
// Stable across edits and feed regenerations, so subscribers update in place
'uid' => "itflow-event-$event_id@$host",
'start' => $event['event_start'],
'end' => $event['event_end'],
'all_day' => $all_day,
'summary' => $event['event_title'],
'location' => calendarWhereText($event),
'description' => trim(strip_tags((string) $event['event_description'])),
// Derived from the event, not the request clock - bytes that changed on every
// fetch could never give an ETag hit
'stamp' => $event['event_updated_at'] ?: $event['event_created_at'],
'created' => $event['event_created_at'],
'sequence' => intval($event['event_sequence'] ?? 0),
'repeat' => $event['event_repeat'] ?? '',
'repeat_interval' => $event['event_repeat_interval'] ?? 1,
'repeat_until' => $event['event_repeat_until'] ?? null,
'skip' => calendarSkippedDates($event),
'busy' => $busy,
];
}
/*
* The feed for one shared calendar: its events through icsCalendar(). $host
* is no longer needed - UIDs come from calendarHost() - and kept for callers.
*/
function buildCalendarFeedIcs(array $calendar, array $events, $timezone, $host = null) {
$busy = !empty($calendar['calendar_feed_busy_only']);
$items = [];
foreach ($events as $event) {
$items[] = calendarEventIcsItem($event, $busy);
}
return icsCalendar(['name' => $calendar['calendar_name'], 'color' => calendarColor($calendar['calendar_color'] ?? '', '')], $items, $timezone);
}
/*
* ---------- an agent's own schedule, as a subscription ----------
*/
/** An agent's schedule link and its settings, or null when they have none. */
function calendarUserFeed($user_id) {
global $mysqli;
$user_id = intval($user_id);
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT * FROM user_calendar_feeds WHERE feed_user_id = $user_id LIMIT 1"));
return $row ?: null;
}
/** The https:// and webcal:// forms of a feed link. */
function calendarFeedUrls($key) {
$host = calendarHost();
$path = '/guest/guest_calendar_feed.php?key=' . rawurlencode((string) $key);
return ['https' => "https://$host$path", 'webcal' => "webcal://$host$path"];
}
/**
* Act as the feed's owner for the rest of the request - their role and the
* clients they may reach - so the permission and client-scope checks the app
* already has decide what the link shows. False when they can no longer sign
* in (disabled, archived, not an agent).
*/
function calendarActAs($user_id) {
global $mysqli, $session_user_id, $session_user_role, $session_is_admin, $client_access_array, $client_deny_array;
$user_id = intval($user_id);
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT user_id, user_role_id, user_status, user_type, user_archived_at, role_is_admin
FROM users LEFT JOIN user_roles ON role_id = user_role_id WHERE user_id = $user_id LIMIT 1"));
if (!$row || intval($row['user_type']) !== 1 || intval($row['user_status']) !== 1 || $row['user_archived_at'] !== null) {
return false;
}
$session_user_id = $user_id;
$session_user_role = intval($row['user_role_id']);
$session_is_admin = intval($row['role_is_admin']) === 1;
$client_access_array = [];
$client_deny_array = [];
$sql = mysqli_query($mysqli, "SELECT client_id, permission_type FROM user_client_permissions WHERE user_id = $user_id");
while ($perm = mysqli_fetch_assoc($sql)) {
if ($perm['permission_type'] === 'deny') {
$client_deny_array[] = intval($perm['client_id']);
} else {
$client_access_array[] = intval($perm['client_id']);
}
}
return true;
}
/**
* The items of an agent's schedule feed, as that agent (calendarActAs()
* first): their scheduled tickets, the events they are on or organised, and
* the projects they manage - or the whole team's, for a feed set to everyone.
* A scheduled ticket carries the client, the site, who to see and how to
* reach them, and a link back to it.
*/
function calendarUserFeedItems(array $feed, $from, $to) {
global $mysqli;
$user_id = intval($feed['feed_user_id']);
$everyone = ($feed['feed_scope'] ?? 'mine') === 'everyone';
$busy = !empty($feed['feed_busy_only']);
$host = calendarHost();
$items = [];
if (!empty($feed['feed_tickets']) && lookupUserPermission('module_support') >= 1) {
$sql = mysqli_query($mysqli, "SELECT tickets.ticket_id, ticket_prefix, ticket_number, ticket_subject, ticket_schedule, ticket_onsite, ticket_client_id,
ticket_created_at, ticket_updated_at, ticket_resolved_at, ticket_closed_at, client_name, user_name,
contact_name, contact_phone, contact_phone_country_code, contact_extension, contact_mobile, contact_mobile_country_code,
location_name, location_address, location_city, location_state, location_zip
FROM tickets
LEFT JOIN clients ON client_id = ticket_client_id
LEFT JOIN users ON user_id = ticket_assigned_to
LEFT JOIN contacts ON contact_id = ticket_contact_id AND ticket_contact_id > 0
LEFT JOIN locations ON location_id = IF(ticket_location_id > 0, ticket_location_id, contacts.contact_location_id)
WHERE ticket_schedule BETWEEN '$from' AND '$to' AND ticket_archived_at IS NULL
" . ($everyone ? '' : "AND ticket_assigned_to = $user_id") . "
" . clientScopeSql('ticket_client_id'));
while ($row = mysqli_fetch_assoc($sql)) {
$ticket_id = intval($row['ticket_id']);
$phone = $row['contact_phone'] ? formatPhoneNumber($row['contact_phone'], $row['contact_phone_country_code']) . ($row['contact_extension'] ? ' x' . $row['contact_extension'] : '') : '';
$mobile = $row['contact_mobile'] ? formatPhoneNumber($row['contact_mobile'], $row['contact_mobile_country_code']) : '';
$link = "https://$host/agent/" . ticketUrl($ticket_id, $row['ticket_client_id']);
$items[] = [
'uid' => "itflow-ticket-$ticket_id@$host",
'start' => $row['ticket_schedule'],
'end' => date('Y-m-d H:i:s', strtotime($row['ticket_schedule']) + CALENDAR_TICKET_MINUTES * 60),
'summary' => calendarTicketTitle($row) . ($everyone && $row['user_name'] ? ' (' . $row['user_name'] . ')' : '') . (ticketIsOpen($row) ? '' : ' [done]'),
'location' => implode(', ', array_filter([$row['location_address'], $row['location_city'], trim($row['location_state'] . ' ' . $row['location_zip'])])),
'description' => implode("\n", array_filter([
$row['client_name'] ? 'Client: ' . $row['client_name'] : '',
$row['location_name'] ? 'Site: ' . $row['location_name'] : '',
$row['contact_name'] ? 'Contact: ' . $row['contact_name'] . ($phone || $mobile ? ' - ' . implode(' / ', array_filter([$phone, $mobile])) : '') : '',
'Assigned: ' . ($row['user_name'] ?: 'Nobody'),
$link,
])),
'url' => $link,
'stamp' => $row['ticket_updated_at'] ?: $row['ticket_created_at'],
'categories' => intval($row['ticket_onsite']) ? 'Onsite' : 'Remote',
'busy' => $busy,
];
}
}
if (!empty($feed['feed_events'])) {
$sql = mysqli_query($mysqli, "SELECT calendar_events.*, location_name, location_address, location_city, location_state, location_zip
FROM calendar_events
JOIN calendars ON calendar_id = event_calendar_id AND calendar_archived_at IS NULL
LEFT JOIN locations ON location_id = event_location_id AND event_location_id > 0
WHERE event_archived_at IS NULL
AND ((event_repeat IS NOT NULL AND event_repeat <> '') OR event_start BETWEEN '$from' AND '$to')
" . ($everyone ? '' : "AND (event_created_by = $user_id OR EXISTS (SELECT 1 FROM calendar_event_attendees a WHERE a.attendee_event_id = event_id AND a.attendee_user_id = $user_id))") . "
" . clientScopeSql('event_client_id') . "
ORDER BY event_start");
while ($row = mysqli_fetch_assoc($sql)) {
$items[] = calendarEventIcsItem($row, $busy);
}
}
if (!empty($feed['feed_projects']) && lookupUserPermission('module_support') >= 1) {
$sql = mysqli_query($mysqli, "SELECT project_id, project_prefix, project_number, project_name, project_start, project_due, project_client_id, project_created_at, project_updated_at, client_name
FROM projects LEFT JOIN clients ON client_id = project_client_id
WHERE " . PROJECT_OPEN_SQL . " AND (project_start IS NOT NULL OR project_due IS NOT NULL)
" . ($everyone ? '' : "AND project_manager = $user_id") . "
" . clientScopeSql('project_client_id'));
while ($row = mysqli_fetch_assoc($sql)) {
foreach (['project_start' => 'starts', 'project_due' => 'due'] as $column => $what) {
if (empty($row[$column])) {
continue;
}
$link = "https://$host/agent/" . projectUrl($row['project_id'], $row['project_client_id']);
$items[] = [
'uid' => 'itflow-project-' . intval($row['project_id']) . "-$what@$host",
'start' => $row[$column] . ' 00:00:00', 'all_day' => true,
'summary' => projectReference($row) . " $what - " . (intval($row['project_client_id']) ? $row['client_name'] . ': ' : '') . $row['project_name'],
'description' => (intval($row['project_client_id']) ? 'Client: ' . $row['client_name'] . "\n" : '') . $link,
'url' => $link,
'stamp' => $row['project_updated_at'] ?: $row['project_created_at'],
'busy' => $busy,
];
}
}
}
return $items;
}
/*
* ---------- invitations ----------
*/
/** Can mail go out from this install? */
function calendarCanEmail() {
global $config_smtp_provider;
return !empty($config_smtp_provider);
}
/**
* Send an event's invitation, its update, or its cancellation to some of its
* attendees - every one with an email when $attendees is null. Each gets a
* short email saying what, when and where, with the event attached as an
* iCalendar invitation their calendar adds - or updates, or removes, matched
* on the UID with the newer SEQUENCE winning. $kind: invite, update, cancel.
* Returns how many were queued.
*/
function calendarSendInvites(array $event, $kind = 'invite', $attendees = null) {
global $mysqli, $config_mail_from_email, $config_mail_from_name, $config_timezone, $session_company_name, $session_name;
if (!calendarCanEmail()) {
return 0;
}
$event_id = intval($event['event_id']);
if ($attendees === null) {
$attendees = calendarEventAttendees($event_id);
}
$attendees = array_values(array_filter($attendees, function ($attendee) {
return filter_var($attendee['email'] ?? '', FILTER_VALIDATE_EMAIL);
}));
if (!$attendees) {
return 0;
}
$cancel = $kind === 'cancel';
$item = calendarEventIcsItem($event);
$item['stamp'] = date('Y-m-d H:i:s');
$item['organizer'] = [$config_mail_from_name ?: $session_company_name, $config_mail_from_email];
$item['attendees'] = array_map(function ($attendee) {
return [$attendee['name'], $attendee['email']];
}, $attendees);
$item['status'] = $cancel ? 'CANCELLED' : 'CONFIRMED';
$ics = icsCalendar(['method' => $cancel ? 'CANCEL' : 'REQUEST', 'invite' => true], [$item], $config_timezone);
$when = calendarWhenText($event);
$repeat = calendarRepeatText($event);
$where = calendarWhereText($event);
$meeting = calendarMeetingUrl($event);
$html = function ($value) {
return escapeHtml((string) $value);
};
$headline = [
'invite' => 'You are invited to the event below. The invitation is attached, so it can go straight into your calendar.',
'update' => 'The event below has changed. The attached invitation updates the one in your calendar.',
'cancel' => 'The event below has been cancelled. The attached cancellation takes it off your calendar.',
][$kind] ?? '';
$subject_prefix = ['invite' => 'Invitation', 'update' => 'Updated', 'cancel' => 'Cancelled'][$kind] ?? 'Invitation';
$details = '<strong>' . $html($event['event_title']) . '</strong><br>'
. '<strong>When:</strong> ' . $html($when) . ($repeat ? '<br><strong>Repeats:</strong> ' . $html($repeat) : '')
. ($where !== '' ? '<br><strong>Where:</strong> ' . $html($where) : '')
. ($meeting && !$cancel ? '<br><strong>Join:</strong> <a href="' . $html($meeting) . '">' . $html($meeting) . '</a>' : '')
. (!$cancel && trim((string) $event['event_description']) !== '' ? '<br><br>' . nl2br($html(trim(strip_tags((string) $event['event_description'])))) : '');
$data = [];
foreach ($attendees as $attendee) {
$body = 'Hello ' . $html($attendee['name']) . ',<br><br>' . $headline . '<br><br>' . $details . '<br><br>--<br>' . $html($session_company_name);
$data[] = [
'from' => mysqli_real_escape_string($mysqli, (string) $config_mail_from_email),
'from_name' => mysqli_real_escape_string($mysqli, (string) $config_mail_from_name),
'recipient' => mysqli_real_escape_string($mysqli, $attendee['email']),
'recipient_name' => mysqli_real_escape_string($mysqli, (string) $attendee['name']),
'subject' => mysqli_real_escape_string($mysqli, mb_substr("$subject_prefix: " . $event['event_title'] . ' - ' . $when, 0, 250)),
'body' => mysqli_real_escape_string($mysqli, $body),
'cal_str' => $ics,
];
}
addToMailQueue($data);
$ids = array_filter(array_map(function ($attendee) {
return intval($attendee['attendee_id'] ?? 0);
}, $attendees));
if (!$cancel && $ids) {
mysqli_query($mysqli, "UPDATE calendar_event_attendees SET attendee_invited_at = NOW(), attendee_invitation_status = 1 WHERE attendee_id IN (" . implode(',', $ids) . ")");
}
logAudit("Calendar Event", "Email", "$session_name sent the " . ($cancel ? 'cancellation' : ($kind === 'update' ? 'update' : 'invitation')) . " for " . escapeSql($event['event_title']) . " to " . count($attendees) . " attendee(s)", intval($event['event_client_id']), $event_id);
return count($attendees);
}
/** The attendees of an event who have been sent its invitation - the ones an update or a cancellation goes to. */
function calendarInvitedAttendees($event_id) {
return array_values(array_filter(calendarEventAttendees($event_id), function ($attendee) {
return !empty($attendee['attendee_invited_at']);
}));
}