-
Notifications
You must be signed in to change notification settings - Fork 16
Ticket Calendar Sync 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
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 |
β οΈ Editingassets/js/calendar.jsmeans bumping?v=NNintickets/calendar.php. Cached hard; without the bump you test the old file and conclude your change did nothing.
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_verifycreates COLUMNS ONLY β keys come from$uniqueIndexesorfreeitsm.sql. Bothcalendar_enrolments.analyst_idandcalendar_sync_events (ticket_id, analyst_id)need a UNIQUE key, and both are written withON DUPLICATE KEY UPDATE. Without the key that silently becomes an INSERT every time. A fresh install fromfreeitsm.sqlwas fine; an upgraded install accumulated duplicate rows and nothing complained. Registered indb_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_datetimeunguarded broke opening any ticket at all on an install that had not run Database Verification yet βget_email_detail.phpandget_scheduled_tickets.phpboth 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.
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.
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 as00:00β23:59so 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.
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()takesassigned_analyst_idand derivesowner_id. Passingowner_idis silently ignored β three reassignment tests "passed" while changing nothing.
calendarSyncPullForAnalyst() reads a Graph delta query and applies what changed. Everything here is about not trusting the answer too much.
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.
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.
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.
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:
-
clientStateβ a random secret per subscription, compared withhash_equals, rotated whenever a subscription is recreated. - 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.
- 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.
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 theContent-Typethat triggers the guard. This is the only place in the product where atext/plainbody is legitimate; the other eight unauthenticatedphp://inputendpoints all receiveapplication/jsonorx-www-form-urlencoded.
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.
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 NULLdelta_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 clearlast_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.
π΄ 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.
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 |
β οΈ dragTicketIdcannot guard the save.dragendfires 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 setscalendarRefreshBusyacross 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.
β οΈ let/constat a script's top level are NOT window properties. A harness readingw.scheduledTicketsgetsundefinedβ which can look like a passing assertion depending how it is written. Usew.eval('scheduledTickets.length').
β οΈ Headless Chrome never runs CSS transitions. Computedvisibilityand bounding rects lie; trustclientHeightvsscrollHeight. 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_datetimeto 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 β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- Theming & Dark Mode
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
-
MobileβFriendly
- β³ π« Mobile: Tickets
- β³ π» Mobile: Assets
- β³ π Mobile: Calendar
- β³ π Mobile: Knowledge
- β³ π¦ Mobile: Service Status
- β³ πΌ Mobile: Watchtower
- β³ π§© Mobile: Problem Management
- β³ π Mobile: Change Management
- β³ πΏ Mobile: Software
- β³ β Mobile: Tasks
- β³ π§° Mobile: Techniques & Tricks
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ π Ticket notes: internal or shared
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- β³ π Scheduled work in your own calendar
- Problem Management
- Tasks
- Assets
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
- Forms
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)