Files
itflow/agent/post/event.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

468 lines
17 KiB
PHP

<?php
/*
* ITFlow - GET/POST request handler for calendars, events and calendar links
*
* Calendars themselves (add, rename, delete, publish) are an admin's. Events
* are anyone's to add, and a client's events need access to that client.
* Attendees are agents, the client's contacts and anyone by email; ticking
* "Email the invitation" sends each of them an iCalendar invitation, and from
* then on a change, a skipped occurrence or the deletion goes to the people
* who have it in their calendar.
*/
defined('FROM_POST_HANDLER') || die("Direct file access is not allowed");
/** A colour from the picker - #rrggbb - or a default. */
function calendarPostedColor($color) {
$color = trim((string) $color);
return preg_match('/^#[0-9a-f]{6}$/i', $color) ? strtolower($color) : '#3c8dbc';
}
/** The attendee fields of the event form, as calendarSetAttendees() takes them. */
function calendarPostedAttendees() {
return [
(array) ($_POST['agents'] ?? []),
(array) ($_POST['contacts'] ?? []),
preg_split('/[\s,;]+/', (string) ($_POST['emails'] ?? ''), -1, PREG_SPLIT_NO_EMPTY),
];
}
/** " - invitation emailed to 3 people" (or just "emailed to") - nothing when nobody was. */
function calendarSentText($count, $what = 'invitation') {
return $count ? ' - ' . ($what ? "$what " : '') . "emailed to $count " . ($count === 1 ? 'person' : 'people') : '';
}
/*
* ---------- calendars ----------
*/
if (isset($_POST['add_calendar'])) {
validateCSRFToken();
enforceAdminPermission();
$name = trim(strip_tags((string) ($_POST['name'] ?? '')));
if ($name === '') {
flashAlert('A calendar needs a name.', 'error');
redirect();
}
$color = calendarPostedColor($_POST['color'] ?? '');
mysqli_query($mysqli, "INSERT INTO calendars SET calendar_name = '" . escapeSql(mb_substr($name, 0, 200)) . "', calendar_color = '$color'");
$calendar_id = mysqli_insert_id($mysqli);
logAudit("Calendar", "Create", "$session_name created calendar " . escapeSql($name), 0, $calendar_id);
flashAlert("Calendar <strong>" . escapeHtml($name) . "</strong> created");
redirect();
}
if (isset($_POST['edit_calendar'])) {
validateCSRFToken();
enforceAdminPermission();
$calendar_id = intval($_POST['calendar_id'] ?? 0);
$name = trim(strip_tags((string) ($_POST['name'] ?? '')));
if ($name === '') {
flashAlert('A calendar needs a name.', 'error');
redirect();
}
$color = calendarPostedColor($_POST['color'] ?? '');
mysqli_query($mysqli, "UPDATE calendars SET calendar_name = '" . escapeSql(mb_substr($name, 0, 200)) . "', calendar_color = '$color' WHERE calendar_id = $calendar_id");
logAudit("Calendar", "Edit", "$session_name edited calendar " . escapeSql($name), 0, $calendar_id);
flashAlert("Calendar <strong>" . escapeHtml($name) . "</strong> saved");
redirect();
}
if (isset($_GET['delete_calendar'])) {
validateCSRFToken();
enforceAdminPermission();
$calendar_id = intval($_GET['delete_calendar']);
$calendar_name = (string) getFieldById('calendars', $calendar_id, 'calendar_name');
// Its events go with it, and whoever was invited to one is told it is off
$sql = mysqli_query($mysqli, "SELECT event_id FROM calendar_events WHERE event_calendar_id = $calendar_id AND event_archived_at IS NULL");
$cancelled = 0;
while ($row = mysqli_fetch_row($sql)) {
$event = calendarEventRow($row[0]);
$invited = calendarInvitedAttendees($row[0]);
if ($event && $invited) {
$cancelled += calendarSendInvites($event, 'cancel', $invited);
}
}
mysqli_query($mysqli, "DELETE calendar_event_attendees FROM calendar_event_attendees JOIN calendar_events ON event_id = attendee_event_id WHERE event_calendar_id = $calendar_id");
mysqli_query($mysqli, "DELETE FROM calendar_events WHERE event_calendar_id = $calendar_id");
mysqli_query($mysqli, "DELETE FROM calendars WHERE calendar_id = $calendar_id");
logAudit("Calendar", "Delete", "$session_name deleted calendar " . escapeSql($calendar_name) . " and its events");
flashAlert("Calendar <strong>" . escapeHtml($calendar_name) . "</strong> and its events deleted" . calendarSentText($cancelled, 'cancellation'), 'error');
redirect();
}
if (isset($_POST['share_calendar'])) {
validateCSRFToken();
// Publishing a calendar mints an unauthenticated public URL, so keep it to
// admins - calendars are not owned by a module.
enforceAdminPermission();
$calendar_id = intval($_POST['calendar_id']);
$busy_only = isset($_POST['busy_only']) ? 1 : 0;
$calendar_name = (string) getFieldById('calendars', $calendar_id, 'calendar_name');
$existing_key = getFieldById('calendars', $calendar_id, 'calendar_feed_key');
if (empty($existing_key)) {
// 32 URL-safe base64 chars (~192 bits). Stored in cleartext, like
// invoice_url_key, so the link stays re-copyable for a second device.
$feed_key = escapeSql(randomString(32));
mysqli_query($mysqli, "UPDATE calendars SET
calendar_feed_key = '$feed_key',
calendar_feed_busy_only = $busy_only,
calendar_feed_created_at = NOW()
WHERE calendar_id = $calendar_id");
logAudit("Calendar", "Share", "$session_name published calendar " . escapeSql($calendar_name) . " as a read-only feed", 0, $calendar_id);
flashAlert("Calendar <strong>" . escapeHtml($calendar_name) . "</strong> published - copy the subscription link from Share");
} else {
mysqli_query($mysqli, "UPDATE calendars SET
calendar_feed_busy_only = $busy_only
WHERE calendar_id = $calendar_id");
logAudit("Calendar", "Edit", "$session_name updated feed settings for calendar " . escapeSql($calendar_name), 0, $calendar_id);
flashAlert("Sharing for <strong>" . escapeHtml($calendar_name) . "</strong> saved");
}
redirect();
}
if (isset($_GET['regenerate_calendar_feed'])) {
validateCSRFToken();
enforceAdminPermission();
$calendar_id = intval($_GET['regenerate_calendar_feed']);
$calendar_name = (string) getFieldById('calendars', $calendar_id, 'calendar_name');
$feed_key = escapeSql(randomString(32));
mysqli_query($mysqli, "UPDATE calendars SET
calendar_feed_key = '$feed_key',
calendar_feed_created_at = NOW(),
calendar_feed_accessed_at = NULL
WHERE calendar_id = $calendar_id");
logAudit("Calendar", "Share", "$session_name regenerated the feed link for calendar " . escapeSql($calendar_name), 0, $calendar_id);
flashAlert("New link for <strong>" . escapeHtml($calendar_name) . "</strong> - the old one has stopped working, so subscribers must subscribe again", 'warning');
redirect();
}
if (isset($_GET['unshare_calendar'])) {
validateCSRFToken();
enforceAdminPermission();
$calendar_id = intval($_GET['unshare_calendar']);
$calendar_name = (string) getFieldById('calendars', $calendar_id, 'calendar_name');
mysqli_query($mysqli, "UPDATE calendars SET
calendar_feed_key = NULL,
calendar_feed_busy_only = 0,
calendar_feed_created_at = NULL,
calendar_feed_accessed_at = NULL
WHERE calendar_id = $calendar_id");
logAudit("Calendar", "Share", "$session_name stopped sharing calendar " . escapeSql($calendar_name), 0, $calendar_id);
flashAlert("Calendar <strong>" . escapeHtml($calendar_name) . "</strong> is no longer shared", 'error');
redirect();
}
/*
* ---------- events ----------
*/
if (isset($_POST['add_event'])) {
validateCSRFToken();
$client_id = intval($_POST['client_id'] ?? 0);
if ($client_id) {
enforceClientAccess();
}
$set = calendarEventFormSet($_POST, $client_id, $error);
if ($set === '') {
flashAlert($error, 'error');
redirect();
}
mysqli_query($mysqli, "INSERT INTO calendar_events SET $set, event_created_by = $session_user_id");
$event_id = mysqli_insert_id($mysqli);
[$agents, $contacts, $emails] = calendarPostedAttendees();
calendarSetAttendees($event_id, $client_id, $agents, $contacts, $emails);
$event = calendarEventRow($event_id);
$sent = !empty($_POST['send_invites']) ? calendarSendInvites($event, 'invite') : 0;
logAudit("Calendar Event", "Create", "$session_name added the event " . escapeSql($event['event_title']) . " to calendar " . escapeSql($event['calendar_name']), $client_id, $event_id);
flashAlert("Event <strong>" . escapeHtml($event['event_title']) . "</strong> added" . calendarSentText($sent));
redirect();
}
if (isset($_POST['edit_event'])) {
validateCSRFToken();
$old = calendarEventForRequest($_POST['event_id'] ?? 0);
$event_id = intval($old['event_id']);
// It can move to another client - which needs access too
$client_id = intval($_POST['client_id'] ?? 0);
if ($client_id) {
enforceClientAccess();
}
$set = calendarEventFormSet($_POST, $client_id, $error);
if ($set === '') {
flashAlert($error, 'error');
redirect();
}
mysqli_query($mysqli, "UPDATE calendar_events SET $set WHERE event_id = $event_id");
// A change a calendar that already has it needs to know about moves it on a version
$event = calendarEventRow($event_id);
$changed = false;
foreach (['event_title', 'event_start', 'event_end', 'event_all_day', 'event_location', 'event_location_id', 'event_description', 'event_repeat', 'event_repeat_interval', 'event_repeat_until'] as $field) {
if ((string) $old[$field] !== (string) $event[$field]) {
$changed = true;
}
}
if ($changed) {
mysqli_query($mysqli, "UPDATE calendar_events SET event_sequence = event_sequence + 1 WHERE event_id = $event_id");
$event['event_sequence'] = intval($event['event_sequence']) + 1;
}
[$agents, $contacts, $emails] = calendarPostedAttendees();
$attendees = calendarSetAttendees($event_id, $client_id, $agents, $contacts, $emails);
$sent = 0;
$cancelled = 0;
if (!empty($_POST['send_invites'])) {
$invite = [];
$update = [];
foreach (calendarEventAttendees($event_id) as $attendee) {
if (!in_array(intval($attendee['attendee_id']), $attendees['new'], true) && !empty($attendee['attendee_invited_at'])) {
$update[] = $attendee;
} else {
$invite[] = $attendee;
}
}
// Newcomers and anyone not yet invited get the invitation; those who have
// it get the update when something about it changed
$sent += calendarSendInvites($event, 'invite', $invite);
if ($changed) {
$sent += calendarSendInvites($event, 'update', $update);
}
// Whoever was taken off and had it in their calendar is told it is off for them
$cancelled = calendarSendInvites($event, 'cancel', array_values(array_filter($attendees['removed'], function ($attendee) {
return !empty($attendee['attendee_invited_at']);
})));
}
logAudit("Calendar Event", "Edit", "$session_name edited the event " . escapeSql($event['event_title']), $client_id, $event_id);
flashAlert("Event <strong>" . escapeHtml($event['event_title']) . "</strong> saved" . calendarSentText($sent + $cancelled, ''));
redirect();
}
if (isset($_POST['send_event_invites'])) {
validateCSRFToken();
$event = calendarEventForRequest($_POST['event_id'] ?? 0);
$event_id = intval($event['event_id']);
if (!calendarCanEmail()) {
flashAlert('Email is not set up, so there is nothing to send the invitation with.', 'error');
redirect();
}
// Those who have it get it again as an update; the rest are invited
$invited = calendarInvitedAttendees($event_id);
$not_invited = array_values(array_filter(calendarEventAttendees($event_id), function ($attendee) {
return empty($attendee['attendee_invited_at']);
}));
$sent = calendarSendInvites($event, 'invite', $not_invited) + calendarSendInvites($event, 'update', $invited);
if (!$sent) {
flashAlert('Nobody on this event has an email address to send it to.', 'warning');
redirect();
}
flashAlert("Invitation for <strong>" . escapeHtml($event['event_title']) . "</strong> emailed to $sent " . ($sent === 1 ? 'person' : 'people'));
redirect();
}
if (isset($_GET['skip_event_occurrence'])) {
validateCSRFToken();
$event = calendarEventForRequest($_GET['skip_event_occurrence']);
$event_id = intval($event['event_id']);
$date = (string) ($_GET['date'] ?? '');
if (!calendarEventRepeats($event) || !calendarIsDate($date)) {
flashAlert('That is not one of its occurrences.', 'error');
redirect();
}
$skip = calendarSkippedDates($event);
$skip[] = $date;
$skip = array_values(array_unique($skip));
sort($skip);
mysqli_query($mysqli, "UPDATE calendar_events SET event_repeat_skip = '" . escapeSql(implode(',', $skip)) . "', event_sequence = event_sequence + 1 WHERE event_id = $event_id");
// The people who have the series in their calendar get it without that day
$event = calendarEventRow($event_id);
$sent = calendarSendInvites($event, 'update', calendarInvitedAttendees($event_id));
$when = date('D, M j', strtotime($date));
logAudit("Calendar Event", "Edit", "$session_name skipped $when of the event " . escapeSql($event['event_title']), intval($event['event_client_id']), $event_id);
flashAlert("<strong>" . escapeHtml($event['event_title']) . "</strong> skipped on $when" . calendarSentText($sent, 'update'));
redirect();
}
if (isset($_GET['delete_event'])) {
validateCSRFToken();
$event = calendarEventForRequest($_GET['delete_event']);
$event_id = intval($event['event_id']);
// The people who have it in their calendar are told it is off
$cancelled = calendarSendInvites($event, 'cancel', calendarInvitedAttendees($event_id));
mysqli_query($mysqli, "DELETE FROM calendar_event_attendees WHERE attendee_event_id = $event_id");
mysqli_query($mysqli, "DELETE FROM calendar_events WHERE event_id = $event_id");
logAudit("Calendar Event", "Delete", "$session_name deleted the event " . escapeSql($event['event_title']), intval($event['event_client_id']), $event_id);
flashAlert("Event <strong>" . escapeHtml($event['event_title']) . "</strong> deleted" . calendarSentText($cancelled, 'cancellation'), 'error');
redirect();
}
if (isset($_GET['export_event_ics'])) {
$event = calendarEventForRequest($_GET['export_event_ics']);
$ics = icsCalendar(['name' => $event['event_title']], [calendarEventIcsItem($event)], $config_timezone);
$filename = trim(preg_replace('/[^A-Za-z0-9_-]+/', '_', $event['event_title']), '_') ?: 'event';
header('Content-Type: text/calendar; charset=utf-8');
header("Content-Disposition: attachment; filename=\"$filename.ics\"");
echo $ics;
exit;
}
/*
* ---------- my schedule, as a subscription ----------
* Each agent's own: their scheduled tickets, the events they are on and the
* projects they manage, for the calendar on their phone, Nextcloud or Google.
*/
if (isset($_POST['calendar_feed_save'])) {
validateCSRFToken();
$scope = ($_POST['scope'] ?? 'mine') === 'everyone' ? 'everyone' : 'mine';
$tickets = !empty($_POST['feed_tickets']) ? 1 : 0;
$events = !empty($_POST['feed_events']) ? 1 : 0;
$projects = !empty($_POST['feed_projects']) ? 1 : 0;
$busy_only = !empty($_POST['busy_only']) ? 1 : 0;
$fields = "feed_scope = '$scope', feed_tickets = $tickets, feed_events = $events, feed_projects = $projects, feed_busy_only = $busy_only";
if (calendarUserFeed($session_user_id)) {
mysqli_query($mysqli, "UPDATE user_calendar_feeds SET $fields WHERE feed_user_id = $session_user_id");
flashAlert('Your calendar link is saved - subscribers pick up the change on their next refresh');
} else {
$feed_key = escapeSql(randomString(40));
mysqli_query($mysqli, "INSERT INTO user_calendar_feeds SET feed_user_id = $session_user_id, feed_key = '$feed_key', $fields");
logAudit("Calendar", "Share", "$session_name created a subscription link to their schedule");
flashAlert('Your calendar link is ready - copy it from My calendar link');
}
redirect();
}
if (isset($_GET['calendar_feed_regenerate'])) {
validateCSRFToken();
$feed_key = escapeSql(randomString(40));
mysqli_query($mysqli, "UPDATE user_calendar_feeds SET feed_key = '$feed_key', feed_created_at = NOW(), feed_accessed_at = NULL WHERE feed_user_id = $session_user_id");
logAudit("Calendar", "Share", "$session_name regenerated the subscription link to their schedule");
flashAlert('New calendar link made - the old one has stopped working, so subscribe again with the new one', 'warning');
redirect();
}
if (isset($_GET['calendar_feed_stop'])) {
validateCSRFToken();
mysqli_query($mysqli, "DELETE FROM user_calendar_feeds WHERE feed_user_id = $session_user_id");
logAudit("Calendar", "Share", "$session_name stopped the subscription link to their schedule");
flashAlert('Your calendar link has stopped working', 'error');
redirect();
}