Skip to content

Ticket Calendar Sync Developer Guide

Ed Mozley edited this page Aug 24, 2026 · 1 revision

Scheduled work in your own calendar β€” Developer Guide

How ticket schedules reach an analyst's real calendar and come back again: the provider contract, why the push reconciles rather than reacts, the three guards on the inbound path, and the traps that have already bitten.

The user-facing page is Scheduled work in your own calendar.

Related: Mailbox Authentication Β· Tickets Β· Architecture


1. πŸ“ The files involved

Colour key: βš™οΈ engine Β· πŸ”Œ API Β· πŸ–₯️ UI Β· ⏰ cron Β· πŸ“– docs

🎨 File What it does
βš™οΈ includes/calendar_sync/CalendarSyncProvider.php the abstract contract every provider implements
βš™οΈ includes/calendar_sync/MicrosoftCalendarProvider.php the Graph implementation β€” the only one so far
βš™οΈ includes/calendar_sync/calendar_sync.php modes, credentials, connection loading, feed policy
βš™οΈ includes/calendar_sync/push.php FreeITSM β†’ calendar. calendarSyncReconcileTicket()
βš™οΈ includes/calendar_sync/pull.php calendar β†’ FreeITSM, plus subscription lifecycle
βš™οΈ includes/ics.php RFC 5545 output β€” escaping, folding, icsRespond()
βš™οΈ assets/js/schedule.js shared duration/naive-time maths for inbox and calendar
πŸ”Œ api/tickets/schedule_ticket.php the one write path for a schedule
πŸ”Œ api/tickets/get_scheduled_tickets.php what the calendar grid draws
πŸ”Œ api/tickets/schedule_feed.php an analyst's .ics feed
πŸ”Œ api/calendar/feed.php the Calendar module's .ics feed (same engine)
πŸ”Œ api/tickets/calendar_enrolment.php an analyst turning it on/off for themselves
πŸ”Œ api/system/calendar_sync.php admin: connection, mailbox map, policy, health
πŸ”Œ api/calendar/graph_notify.php Microsoft's change-notification endpoint
⏰ cron/calendar_sync_pull.php renews subscriptions and polls
πŸ–₯️ system/calendar-sync/index.php the admin screen
πŸ–₯️ system/preferences/index.php My work calendar β€” the analyst's own choice
πŸ–₯️ tickets/calendar.php + assets/js/calendar.js the grid, drag-to-reschedule, auto-refresh
πŸ–₯️ includes/subscribe_modal.php, assets/js/subscribe.js, assets/css/subscribe.css the shared subscribe dialogue
πŸ“– tickets/help-calendar-sync.php the in-product guide

⚠️ Editing assets/js/calendar.js means bumping ?v=NN in tickets/calendar.php. Cached hard; without the bump you test the old file and conclude your change did nothing.


2. Schema

Three tables, all created by freeitsm.sql and by api/system/db_verify.php.

Table Holds
calendar_connections one row per connection: provider, credentials (or mailbox_id to borrow), allow_feed, last error
calendar_enrolments one row per analyst: mode, calendar_address, delta token, subscription id/expiry/secret, last_error
calendar_sync_events the map: ticket_id + analyst_id β†’ remote_event_id, remote_calendar

Plus two columns on tickets, added alongside the existing work_start_datetime:

Column Why
work_end_datetime Graph cannot create an appointment without an end
work_all_day an all-day job is not a 24-hour block

πŸ”΄ db_verify creates COLUMNS ONLY β€” keys come from $uniqueIndexes or freeitsm.sql. Both calendar_enrolments.analyst_id and calendar_sync_events (ticket_id, analyst_id) need a UNIQUE key, and both are written with ON DUPLICATE KEY UPDATE. Without the key that silently becomes an INSERT every time. A fresh install from freeitsm.sql was fine; an upgraded install accumulated duplicate rows and nothing complained. Registered in db_verify.php:

['calendar_enrolments',  'uniq_calendar_enrolment_analyst',  '(`analyst_id`)'],
['calendar_sync_events', 'uniq_calendar_sync_ticket_analyst', '(`ticket_id`, `analyst_id`)'],

πŸ”΄ Guard every new column behind a schema check. Shipping work_end_datetime unguarded broke opening any ticket at all on an install that had not run Database Verification yet β€” get_email_detail.php and get_scheduled_tickets.php both selected it. scheduleSchemaReady() now gates those reads. The failure mode is nasty because it has nothing to do with calendars: the feature you added is not the feature that breaks.


3. The provider contract

CalendarSyncProvider follows the same shape as IssueTrackerProvider and MessagingProvider.

abstract public function createEvent(string $calendarAddress, array $event): string;
abstract public function updateEvent(string $calendarAddress, string $remoteEventId, array $event): void;
abstract public function deleteEvent(string $calendarAddress, string $remoteEventId): void;
abstract public function verifyConnection(): void;

public function verifyTarget(string $calendarAddress): bool;
public function pollChanges(string $calendarAddress, ?string $token): array;
public function createSubscription(string $calendarAddress, string $notifyUrl, string $secret): array;
public function renewSubscription(string $subscriptionId): array;
public function deleteSubscription(string $subscriptionId): void;

Two exception types carry meaning the caller acts on:

Exception The caller's correct response
CalendarEventMissing somebody deleted it in the calendar β€” create a fresh one, do not error
CalendarSubscriptionMissing it lapsed β€” create a fresh one, do not error

πŸ”‘ These are classes rather than error strings because the response is specific. "Not found" is a normal outcome here, not a failure β€” a user tidying their calendar is expected behaviour, and code that treats it as an error either spams the log or gives up syncing.

verifyConnection() and verifyTarget() are separate on purpose. The admin Test button reports them as two answers: do the credentials work and can this mailbox be reached have entirely different fixes, and merging them into one "it failed" leaves you guessing.

Microsoft specifics

App-only client credentials against /users/<address>/events β€” no per-analyst OAuth, no consent flow, no refresh tokens. Credentials are usually borrowed from an existing mailbox's app registration (calendar_connections.mailbox_id), so most installs register nothing new.

⚠️ All-day events convert, they do not merely flag. FreeITSM stores an all-day ticket as 00:00–23:59 so anything ignoring the flag still gets a sensible block. Graph wants a date-only start and an exclusive end:

$startDay = substr($event['start'], 0, 10);
$endDay   = (new DateTime(substr($event['end'], 0, 10)))->modify('+1 day')->format('Y-m-d');

Same rule as iCalendar DTEND. Get it wrong and every all-day job renders a day short.


4. Push: reconcile, don't react

calendarSyncReconcileTicket($conn, $ticketId, $gone = false) is the only entry point. It does not take an instruction like "create" or "delete". It works out what should exist for that ticket and makes it true.

That is what makes the awkward cases fall out for free rather than each needing its own branch:

What happened What reconcile does
scheduled for the first time no row β†’ create
rescheduled row exists β†’ update
reassigned to another analyst old analyst's row no longer should exist β†’ delete there, create in the new calendar
unscheduled / closed / deleted nothing should exist β†’ delete
analyst opted out calendarSyncRemoveAllForAnalyst()
user deleted it in Outlook CalendarEventMissing β†’ create a fresh one

πŸ”‘ A reaction-based design needs a code path per transition and grows one every time somebody invents a new way to change a ticket. Reconcile has one path and a single question: what should be there now?

Because every schedule write goes through api/tickets/schedule_ticket.php, a drag on the calendar, the inbox modal and the REST API all reconcile identically. Dragging is not a second way to write a schedule; it is a different way to say the same thing.

⚠️ updateTicket() takes assigned_analyst_id and derives owner_id. Passing owner_id is silently ignored β€” three reassignment tests "passed" while changing nothing.


5. Pull: three guards

calendarSyncPullForAnalyst() reads a Graph delta query and applies what changed. Everything here is about not trusting the answer too much.

Guard 1 β€” never act on a lost baseline

If there is no delta token, the sync takes a baseline and applies nothing. An empty or wholesale-different answer can mean everything was deleted or I have no idea where I am, and only one is safe to act on. The cron reports baseline taken (nothing applied) explicitly, because "I have just learned where I am" and "nothing changed" are different states and only one is worth worrying about twice.

Guard 2 β€” a deletion cap

const CALENDAR_DELETE_CAP = 5;

More than five removals in one poll is refused and reported, not obeyed. Mass deletion is far more likely to be a fault β€” a mailbox move, a retention policy, a bad token β€” than an instruction.

Guard 3 β€” audit everything

calendarPullAudit() writes every inbound change to the ticket history, naming the calendar it came from. A ticket must never silently move with no record of why.

On top of those, accepting deletions at all is opt-in (tickets_calendar_accept_deletes, default off).

πŸ”‘ Echo suppression is by COMPARISON, not by marker. After FreeITSM writes an event, Graph reports it as changed. Rather than tagging events as "ours" β€” which fails the moment anything else touches them β€” the pull compares the incoming value against what the ticket already says and does nothing when they match. Idempotent, and it cannot be defeated by an edit that strips a marker.


6. Subscriptions and the notification endpoint

cron/calendar_sync_pull.php does both jobs: renews subscriptions, then polls.

πŸ”΄ Notifications are an accelerator, never a replacement. Graph caps calendar subscriptions at ~3 days, so without the cron they lapse. And a missed notification is indistinguishable from nothing having changed β€” so the poll stays as the backstop unconditionally.

Renewal happens with six hours of slack rather than at the last moment: a blip in the cron then costs nothing.

api/calendar/graph_notify.php is public and unauthenticated by necessity β€” Microsoft's servers cannot carry a session. Three things make that safe:

  1. clientState β€” a random secret per subscription, compared with hash_equals, rotated whenever a subscription is recreated.
  2. The body is never trusted for content. A notification says only this subscription saw a change; what changed is then read from Graph with our own credentials. A forged notification can at worst cause a poll of a calendar we already sync.
  3. Nothing writes a ticket directly β€” it calls the same pull path as the cron, guards and all. A notification changes when we look, never what we are willing to do.

The endpoint answers 202 and calls fastcgi_finish_request() before doing any work, because Graph retries if it does not get an answer within about three seconds β€” which would mean polling the same calendar several times over.

πŸ”΄ The trap: the CSRF guard blocks Graph's handshake

When a subscription is created, Graph immediately POSTs with ?validationToken=… and an empty body declared Content-Type: text/plain, expecting the token echoed back within ten seconds.

includes/request_guard.php β€” reached via functions.php, and running on include β€” answers 415 to exactly that, because no browser has an honest reason to send text/plain and it is the one CORS-simple type that dodges a preflight.

Two correct pieces of code colliding. The symptom is only ever seen from Microsoft's side:

Subscription validation request failed. HTTP status code is 'UnsupportedMediaType'.

The handshake must therefore run BEFORE the require lines, not merely first in the file. That is safe because the branch does nothing: no session, no database, no cookie read, no state changed. It echoes one query parameter as inert text/plain (with nosniff) and exits. Real notifications arrive as application/json and pass the guard normally.

⚠️ Test the handshake with a POST, not a GET. A GET sails through and proves nothing β€” it does not carry the Content-Type that triggers the guard. This is the only place in the product where a text/plain body is legitimate; the other eight unauthenticated php://input endpoints all receive application/json or x-www-form-urlencoded.


7. The ICS feed

includes/ics.php is the shared RFC 5545 writer, used by both the tickets feed and the Calendar module's.

Rule Why it bites
CRLF line endings required by spec; bare \n is rejected by strict parsers
75-octet line folding octets, not characters β€” folding mid-UTF-8-sequence corrupts it
DTEND is EXCLUSIVE for all-day an all-day event on the 5th ends on the 6th
Escape , ; \ and newlines an unescaped comma silently splits a field

Feed access is a capability URL β€” a secret token in the query string. Hence the "treat it like a password" copy, the HTTPS warning, and Reset issuing a new token rather than editing an ACL.

Policy lives in calendar_sync.php: FEED_MODE_OFF / FEED_MODE_REF (numbers only) / FEED_MODE_FULL. scheduleFeedDetail() resolves the admin ceiling against the analyst's own choice β€” the analyst can be more private than the ceiling, never less.


8. Health, and the failure that stays quiet

api/system/calendar_sync.php returns last_poll_minutes alongside the per-analyst subscription state.

πŸ”‘ This exists because a stopped cron is the one failure that never announces itself. A broken connection errors. A subscription that will not create errors. A scheduled job that has stopped looks exactly like a calendar in which nothing changed, and stays looking healthy for weeks while every inbound change is lost.

Measured in SQL, not against the browser clock:

SELECT TIMESTAMPDIFF(MINUTE, MAX(delta_synced_datetime), NOW())
  FROM calendar_enrolments
 WHERE mode <> 'off' AND delta_synced_datetime IS NOT NULL

delta_synced_datetime is written with NOW(), so comparing it to NOW() keeps both sides on one clock and sidesteps the timezone question entirely.

Warning threshold is 30 minutes, worded as a fact plus where to look β€” never a bare "problem" β€” because FreeITSM cannot know what interval was chosen and an admin polling hourly on purpose should recognise their own decision.

⚠️ A success must clear last_error. It was written on failure but only ever cleared by a successful push, so a system that failed once and then recovered kept a red pill up indefinitely. calendarStoreSubscription() β€” the success path for both create and renew β€” now clears it. A stale error is worse than none: it sends you hunting a fault already fixed.


9. Time handling

πŸ”΄ FreeITSM scheduling times are NAIVE wall-clock values. Never round-trip one through toISOString(). It treats the value as local and emits UTC, so re-opening a ticket scheduled just after midnight showed the previous day for anyone ahead of UTC β€” and saving accepted the wrong date.

assets/js/schedule.js holds the shared maths β€” parseNaive, formatNaive, toStoredRange, durationMinutes β€” used by both the inbox modal and the calendar panel, so the two screens cannot drift.

Duration is asked for, an end is stored. Asking "how long?" rather than "what time does it finish?" makes an end-before-start inexpressible rather than something to validate afterwards. A NULL end is resolved to the default on read, so nothing needed backfilling and no existing ticket was altered.

⚠️ Test timezone conversions on a date where the zones actually differ. An assertion written in March β€” when Europe/London is UTC β€” cannot fail. This one got past me twice.


10. The calendar grid

tickets/calendar.php + assets/js/calendar.js.

Auto-refresh polls every 10 seconds but repaints only when a signature of the rendered fields changes. A poll that rebuilt the grid on every tick would drop hover states, close tooltips and flicker the month six times a minute, almost always to draw exactly what was already there. The signature is sorted, so the server returning the same tickets in a different order is not mistaken for a change.

Four situations skip a refresh entirely rather than queueing it:

Skipped when Because
a modal is open showTicketDetail holds a reference into scheduledTickets, and a refresh replaces that array β€” the modal would edit an object no longer connected to the grid
mid-drag re-rendering destroys the dragged element
a drag is saving dragTicketId is already null by then, so it needs its own flag β€” see below
the tab is hidden otherwise every open calendar in the building polls all day for nobody

⚠️ dragTicketId cannot guard the save. dragend fires on mouse release, long before the server answers. applyDrag() paints optimistically and rolls back on refusal, so a refresh landing in that window would either overwrite the optimistic paint or resurrect the rolled-back state. It sets calendarRefreshBusy across the whole save.

Owner colours are assigned by POSITION, not id % palette.length. Analysts 1 and 41 both landed on the same green while the legend confidently showed them as separate people β€” any two ids ten apart collided.


11. Testing notes

⚠️ let/const at a script's top level are NOT window properties. A harness reading w.scheduledTickets gets undefined β€” which can look like a passing assertion depending how it is written. Use w.eval('scheduledTickets.length').

⚠️ Headless Chrome never runs CSS transitions. Computed visibility and bounding rects lie; trust clientHeight vs scrollHeight. Use --headless=old --enable-logging=stderr --log-level=0, drive the real page in a same-origin iframe, and always include a negative control β€” an assertion that must fail. Several "passing" suites here turned out to be incapable of failing.

Useful proofs when changing this area:

  • Stamp a marker node into the grid: it must survive a refresh that finds no change and be destroyed by one that does.
  • Test the Graph handshake with a genuine POST … Content-Type: text/plain, Content-Length: 0.
  • Age delta_synced_datetime to prove the stale-health branch renders β€” the guidance note only exists inside that branch.
  • Break the credentials deliberately, then fix them, and check the error pill actually clears.

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally