Repository navigation
Ticket Workspace Developer Guide
How the ticket workspace is put together: three switches owned by three different people, one renderer that draws either layout, and the machinery that stops a re-render landing on top of unsaved work. Also the linked-task editor that opens in its Work tab.
User page: The ticket workspace. Design: The ticket workspace - design (PR #206). Review: Ticket workspace, task editor and table - review (#211-#213).
Contributed by @Dr4inder as #211 (the workspace) and #212 (tasks inside it), the first two of the three pull requests that replaced draft #206. Changelog #2633, #2634, #2637.
Colour key: 🗄️ schema · 📖 read · ✏️ write · 🖥️ UI · 🔌 REST · 🧪 tests
| File | 🎨 | What it does |
|---|---|---|
includes/ticket_workspace.php |
📖 |
TicketWorkspaceMode enum, ticketWorkspaceEnabled() (the whole rule), ticketWorkspaceReady() (column guard), ticketWorkspaceConfig() (what the page hands the browser) |
database/freeitsm.sql, includes/db_verify_schema.php
|
🗄️ | tickets.is_workspace TINYINT NOT NULL DEFAULT 0 |
includes/services/tickets.php |
✏️ |
updateTicket() - the workspace key: validated, guarded, audited server-side, and a flag-only change returns before workflows |
api/tickets/assign_ticket.php |
✏️ | Passes workspace through from More → Make workspace |
api/tickets/get_email_detail.php |
📖 | Returns is_workspace with the ticket (0 before Database Verification) |
api/v1/resources/tickets.php, api/v1/spec.json, api/v1/lib/openapi_schemas.php
|
🔌 |
workspace boolean on the ticket and on PATCH /tickets/{id}
|
api/settings/save_system_settings.php |
✏️ | Refuses a ticket_workspace_mode that is not a TicketWorkspaceMode case |
tickets/settings/index.php, tickets/settings/manifest.php
|
🖥️ | The General tab's Ticket workspace dropdown; the key is under TICKETS_GENERAL
|
tickets/index.php |
🖥️ |
window.TICKET_WORKSPACE, the Use the workspace toggle in the view menu, the workspace-choice-available body class |
system/preferences/index.php, includes/preference_templates.php
|
🖥️ | The personal switch (ticket_workspace), and its template section |
assets/js/inbox.js |
🖥️ |
displayEmail() (the one renderer), ticketReadGuard(), ticketPendingWrites / trackTicketWrite(), buildWorkspaceAction() / setTicketWorkspace(), and a setSectionState() call in each section loader |
assets/js/ticket-workspace.js |
🖥️ |
TicketWorkspace: arranging, tabs, section states, leaving, holding a re-render, the personal switch, the task frame |
assets/css/ticket-workspace.css |
🖥️ | All of it scoped to .ticket-workspace / .ticket-workspace-mode
|
assets/js/mobile.js |
🖥️ | Skips its toolbar overflow and section moves for a workspace; showAuditHistory(true) stays inline on a phone |
checklists/ticket_view.js |
📖 | The checklist loader reports its state to the Work tab |
| The task editor (PR #212) | ||
tasks/embedded.php |
📖 | The frame's page: checks, then runs tasks/index.php embedded |
includes/task_embed.php |
📖 |
taskEmbedCanOpen() - ticket, root task and subtask chain |
api/tasks/get.php |
📖 | Re-checks the embed context on every read from the frame |
tasks/index.php, tasks/includes/detail-shell.php
|
🖥️ | The embedded variant; the detail panel's markup, now shared |
assets/js/task-embed-bridge.js |
🖥️ | The frame's side: unsaved state, sizing, "may I close you?" |
assets/js/tasks.js |
🖥️ |
TASK_HOST === 'ticket' branches, data-task-field on fields, bridge calls |
assets/css/task-embed.css |
🖥️ | The detail panel as page content |
tests/ticket-workspace*.php, tests/task-embed*.php, tests/ticket-task-live.php, tests/support/ticket-workspace-*.php
|
🧪 | See section 9 |
What you do not touch: the folder pane and the message list. Choosing a ticket opens it in the reading pane or the workspace; everything to the left behaves as before.
| Switch | Stored in | Owner | Default |
|---|---|---|---|
| Install mode |
system_settings.ticket_workspace_mode (off / selected / all) |
An administrator (Tickets → Settings → General) | Off - an upgrade changes nothing |
| Personal |
user_preferences.ticket_workspace ('0' / '1') |
Each analyst |
On - only an explicit '0' opts out (ticketWorkspacePersonal()) |
| Ticket flag | tickets.is_workspace |
Anyone who can change the ticket (More → Make workspace) | 0 |
function ticketWorkspaceEnabled(TicketWorkspaceMode $mode, bool $personal, bool $flag): bool
{
return $personal && ($mode === TicketWorkspaceMode::All
|| ($mode === TicketWorkspaceMode::Selected && $flag));
}🔑 TRAP: the same rule lives twice - here, and as
TicketWorkspace.enabled()in the browser, which readswindow.TICKET_WORKSPACEand the ticket'sis_workspace. The PHP one exists sotests/ticket-workspace-settings.phpcan walk all 12 combinations. Change one, change both.
None of the three ever clears another: switching the install to Off and back brings back the same flagged tickets.
Before Database Verification the column does not exist. ticketWorkspaceReady() checks once per request; the detail read selects 0 AS is_workspace, the page's ready: false hides Make workspace, and updateTicket() refuses the key with Run System → Database Verification.
The design asked for this, and it is the most important thing to keep: the workspace does not move the reading pane's controls after they are drawn. An earlier draft did, and that meant any later change to the reading pane could break the workspace with nothing noticing.
Instead displayEmail() in assets/js/inbox.js builds each piece once, then puts the pieces together for whichever layout is in use:
// Shortened from displayEmail() - the markup and the .join('')s are left out.
const workspace = !!window.TicketWorkspace?.enabled(email);
// TRAP: define controls once and choose their destinations while rendering.
// Relocating a rendered classic pane would quietly lose future controls.
const propertyFields = {
department: `<div class="toolbar-field">...<select id="departmentSelect" onchange="assignDepartment()">...`,
type: ..., classification: ..., status: ..., priority: ..., sensitivity: ...,
origin: ..., firstTimeFix: ..., training: ..., team: ..., owner: ..., company: ...,
custom: `<div class="tf-panel-fields" id="ticketCustomFields"></div>`,
};
const propertyFieldsHtml = workspace
? TicketWorkspace.fieldGroup('classification', [department, type, classification, status, priority]) +
TicketWorkspace.fieldGroup('responsibility', [team, owner, company, sensitivity]) +
TicketWorkspace.fieldGroup('other', [origin, firstTimeFix, training, custom])
: Object.values(propertyFields).join(''); // the classic order: the object's key order
...
const contentHtml = workspace ? TicketWorkspace.render({
header: headerHtml + summaryHtml + companyWarningBanner + bannersHtml + actionsHtml,
properties: propertiesHtml,
conversation: attachmentHtml + aiHtml + recordingsHtml + threadHtml + notesHtml,
work: checklistHtml + '<div id="twTaskList"></div><div id="twTaskArea"></div>',
links: linksHtml,
time: timeHtml,
}) : propertiesHtml + companyWarningBanner + headerHtml + ... + '<div class="email-body">' + ... + '</div>';
readingPane.innerHTML = trashBanner + contentHtml;
window.TicketWorkspace?.prepare(readingPane, {ticketId: email.ticket_id, enabled: workspace});Because each control keeps its id (statusSelect, threadContainer, slaContainer...) in both layouts, every loader and handler in assets/js/inbox.js works unchanged in either. That is why the workspace needed so little in the loaders - just a state report (section 5).
⚠️ The classic order is the object's key order.Object.values(propertyFields)is what draws the reading pane's toolbar, so inserting a key in the middle of the object moves that field in the reading pane too. Put it where you want it to appear there.
- Add it to the right piece in
displayEmail()- a property field topropertyFields, a toolbar button toactionsHtml, a strip tolinksHtml, a section to the conversation / time pieces. - For a property, add its key to one of the three
fieldGroup()lists, or it will be missing from the workspace's column. - If it loads asynchronously, start the loader with
const isCurrent = ticketReadGuard(ticketId), checkisCurrent()after everyawait, and reportwindow.TicketWorkspace?.setSectionState(ticketId, '<tab>', 'ready' | 'error', null, '<your source>'). - If it writes, nothing more: the workspace's fetch counter (section 6) sees it. If its handler re-draws something after saving, wrap it with
trackTicketWrite(). - Check it in both layouts, and at phone width.
TicketWorkspace.render() builds the header, the properties <aside> and five tab panels (conversation, work, links, time, history) with the WAI-ARIA tabs pattern - arrow keys, Home and End move between tabs. ?section=work opens straight on one.
- Switching tab only hides panels. Inputs on a hidden tab stay live and keep what was typed.
-
The selected tab survives a re-render of the same ticket (
prepare()keepsactive); a different ticket starts on Conversation. -
History is loaded on first opening -
showAuditHistory(true), wheretruedraws the table into#twAuditBodyinstead of a modal.assets/js/mobile.jswrapsshowAuditHistoryto open its own sheet on a phone, and letstruethrough. - A required field failing validation on a hidden tab switches to that tab (an
invalidlistener), and a blocked close (confirmCloseWithMandatoryFields()) callsrevealMissing()to take the analyst to the first missing field. - Toolbar actions that would show something on another tab switch to it: Link to... (Links), a channel Reply (Conversation).
Each loader that fills a tab reports its own state, under its own source, so the thread failing does not hide that the notes loaded, and one success cannot clear another's error:
window.TicketWorkspace?.setSectionState(ticketId, 'links', 'error', null, 'cmdb');In the classic reading pane these calls do nothing (there is no #tw-panel-links), and each loader keeps its old behaviour there - the strips stay silent on a failed read.
ticketReadGuard() replaces "is the same ticket id still showing?" with "is the same render still showing?":
function ticketReadGuard(ticketId) {
const generation = ticketRenderGeneration; // bumped by every displayEmail()
return () => generation === ticketRenderGeneration && currentEmail && Number(currentEmail.ticket_id) === Number(ticketId);
}A ticket id is not enough: re-opening the same ticket (or a held render being drawn) replaces the DOM, and a slow response from the old render would write into elements that no longer exist - or, worse, into the new ones with old data. selectEmail() and loadTicketById() have the same idea for the ticket itself (ticketSelectionGeneration).
Three different things could otherwise wipe what the analyst was doing: going to another ticket, switching layout, and the same ticket being re-drawn - loadTicketById() runs again after the open ticket is dragged to a folder, escalated to a tracker, or made a workspace.
| Mechanism | Where | What it does |
|---|---|---|
TicketWorkspace.canLeave() |
Called before the ticket on screen is replaced: selectEmail, loadTicketById, handleEmailRowClick, onSelectionChanged, clearSelectionToNone, the summary pane, Make workspace, the layout switches |
false while a write is in flight; otherwise asks to Discard anything unsaved, and on yes resets the custom fields and closes the drafts |
holdRender() / flushRender()
|
First line of displayEmail()
|
A re-render of the same ticket while anything is busy is parked, and drawn when the work finishes. A different ticket is never held - that is navigation, and canLeave() was asked |
ticketPendingWrites |
assets/js/inbox.js |
Writes still in flight |
propertyDrafts |
assets/js/ticket-workspace.js |
A focused custom field's value when focused - those fields save on change, so a typed-but-unchanged value is visible only here |
ticketHasUnsavedInput() / discardTicketDraft()
|
assets/js/inbox.js |
The reply and note modals, and the channel composer |
beforeunload |
assets/js/ticket-workspace.js |
The browser's own warning on a reload or tab close |
Most of the reading pane's writers (assignStatus, saveTicketCustomField, the link pickers...) are plain fetch() calls that report nothing. Rather than edit every one, assets/js/ticket-workspace.js wraps window.fetch for the whole tickets page and counts each same-origin non-GET request to /api/ while a workspace is on screen:
ticketPendingWrites++;
const finish = () => setTimeout(() => { ticketPendingWrites--; flushRender(); }, 0);
const response = await nativeFetch(input, init);
response.clone().text().then(finish, finish); // released once the BODY is read- It is inert unless a workspace is showing - the classic pane is untouched.
- It sits on top of
assets/js/csrf.js's wrapper, so the CSRF header is still added. Load order matters:assets/js/csrf.jsfirst. -
api/system/set_user_preference.phpis not counted, so the layout switch does not wait for itself. - The count is released only after the response body has been read and the caller's own
.then()has run (thesetTimeout) - otherwise a held render could be drawn between "saved" and the handler updating the screen. - Writers that re-load the thread after saving (
saveNote,sendEmail,sendChannelMessage) also go throughtrackTicketWrite(), which counts their whole handler.
If you add a write that does not go through fetch() (an XMLHttpRequest, a form post), it is invisible to this. Use fetch().
function buildWorkspaceAction(email) {
const config = window.TICKET_WORKSPACE;
if (!email || email.deleted_datetime || config?.mode !== 'selected' || !config.ready || !config.can_write) return '';
...
}Offered only in Only tickets turned into a workspace mode (design decision 3), not for a ticket in the trash, not before Database Verification, and not without write access - Tickets has no Read only level yet, so can_write is true for everyone today, but it is the right question and it starts working the day Tickets gets one. The More button is normally desktop-only in the reading pane; body.workspace-choice-available shows it in this mode so the item can be reached.
setTicketWorkspace() asks canLeave(), posts {ticket_id, workspace} to api/tickets/assign_ticket.php, then reloads the ticket rather than trusting a refresh that might still carry the old flag.
if (array_key_exists('workspace', $in)) {
if (!is_bool($in['workspace'])) throw new ServiceError('validation', 'invalid_field', "'workspace' must be a boolean.");
if (!ticketWorkspaceReady($conn)) throw new ServiceError(... 'Run System → Database Verification ...');
if ($in['workspace'] !== (bool)($current['is_workspace'] ?? false)) {
$updates[] = 'is_workspace = ?';
$audits[] = ['Workspace', 'off'/'on', 'on'/'off', true]; // true = forced
$workspaceChanged = true;
}
}- The audit is forced (written even when the UI caller asked for no server-side audit), so the flag's history never depends on which channel changed it.
- A flag-only change returns before workflows, emails and calendar sync - it changes how a ticket is shown, not the work. A save that changes the flag and something else runs everything as normal.
🔑 TRAP (in the code): test what actually changed, not which keys were sent.
$workspaceOnly = $workspaceChanged && count($updates) === 1 && $customValues === null- a client that resends every field with only the flag different must still skip the workflows, and a client that sends the flag unchanged alongside a real change must still run them.
When the flag changes, the update and its audit row are written in one transaction (joining the caller's if it has one).
The full write-up of what the embedded page relies on is in Tasks on a ticket - developer guide, section 10. In short:
- The task pills on the Work tab call
TicketWorkspace.openTask()on a plain click (Ctrl/Shift/Alt-click still opens the task separately). -
openTask()asks the open task (if any) whether it can close, then putstasks/embedded.php?task=N&ticket_id=Min a same-origin<iframe>, with Open separately and Close above it as a fallback until the editor says it has its own. - The two sides talk by
postMessageonly. Every message is checked for origin, sender window, and the same{ticketId, taskId}. -
taskCanLeave()sendsfreeitsm:task-embed:leaveand waits (up to 45 seconds; silence counts as no) for the editor to flush its description, wait out its writes and ask to Discard. - While a task is open,
holdRender()treats the ticket as busy, so a refresh never destroys the frame. - After the ticket's tasks reload,
tasksLoaded()tells the editor if its task is no longer linked.
🔴 TRAP: not
crypto.randomUUID(). The leave request's id was a UUID. Browsers only providecrypto.randomUUID()in a secure context - HTTPS orlocalhost- and FreeITSM is often reached over plain HTTP inside a network. There it threw, and no ticket could be left while a task was open. The id only has to tell this page's own questions apart, so it is a counter. The same rule applies tonavigator.clipboard(seeassets/js/clipboard.js). Found in review; see the review.
| Test | Needs | What it covers |
|---|---|---|
tests/ticket-workspace-settings.php |
nothing | The enum, the personal default, all 12 combinations of the three switches, the before-verification fallbacks |
tests/ticket-workspace.php |
Chrome |
displayEmail() in both layouts against a stub page: every control present once, trash and snooze actions kept, the badge, Make workspace's conditions |
tests/ticket-workspace-actions.php |
Chrome | Leaving with drafts (decline keeps them, accept discards), stale reads, held renders, the personal switch |
tests/task-embed-bridge.php |
Chrome | The bridge: sizing, messages, drafts, nested forms, editor baselines |
tests/ticket-workspace-api.php |
database + site | UI and REST writes, the audit row, flag-only does not fire a workflow and a mixed change does (a real workflow as the positive control), CSRF, company scope, read-only API keys |
tests/task-embedded-workspace.php |
database + site | Forged roots, unrelated tasks, another company, missing module access, read-only writes, unlinking |
tests/ticket-workspace-live.php, tests/ticket-task-live.php
|
database + site + Chrome | The real page: notes, time, both flag directions, 390-1440 px, dark theme, the native task editor saving, checklists, forms, subtasks |
The database tests need FREEITSM_TEST_URL (a localhost install) and FREEITSM_TEST_DATABASE (that install's database name, checked against SELECT DATABASE()), create ZZ fixtures and delete them by id in finally. Point them at a disposable install, not one with real data: they create companies, analysts and tickets.
⚠️ The live tests once assumed FreeITSM was at the web root (fetch('/auth/login.php'), a pathname of exactly/tickets/case-report.php) and failed on a subfolder install. Build every URL fromFREEITSM_TEST_URLor relative to the page.
A disposable install that works: a git worktree of the branch under the web root, a config.php pointing at its own db_config.php, and a fresh database loaded from database/freeitsm.sql.
FreeITSM — an open-source IT Service Management platform · github.com/edmozley/freeitsm · MIT licence
- Installation
- ⏰ Scheduled tasks (cron jobs)
-
Architecture
- ↳ 🧹 Root folder tidy (URL routing)
- ↳ 🔗 Database Integrity (FKs & constraints)
- ↳ 🛠️ Database Verification — Developer Guide
- ↳ 🧩 Service Layer (UI + API share code)
- ↳ 🪤 Code traps (the TRAP: comments)
- ↳ 📝 Commenting for the Engineering wiki
- ↳ 🛠️ Engineering wiki scanner - Developer Guide
- ↳ 📊 Service-Layer progress
- 🤝 Contributing (read before a pull request)
- 🧪 Developer tests
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- 📅 Date & Time Formats
- 💱 Currencies
- 🧭 The sidebar (optional module rail)
- 🧩 Preference templates
- 🔀 Display order (modules, Watchtower cards) — Developer Guide
- Theming & Dark Mode
- 🗂️ Recent — getting back to what you were doing
- ⌨️ 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: Forms
- ↳ 📄 Mobile: Contracts
- ↳ 📄 Mobile: Domains
- ↳ 📄 Mobile: People
- ↳ 🚀 Mobile: Projects
- ↳ 🗂️ Mobile: Files
- ↳ 🎓 Mobile: LMS
- ↳ 🗺️ Mobile: CMDB
- ↳ 🗺️ Mobile: Network Mapper
- ↳ 🧭 Mobile: Process Mapper
- ↳ ⚙️ Mobile: Workflow
- ↳ 🖥️ Mobile: System
- ↳ 📊 Mobile: Reporting
- ↳ 📖 Mobile: System Wiki
- ↳ 🙋 Mobile: Self-Service Portal
- ↳ 🧰 Mobile: Techniques & Tricks
-
Security
- Layer 1 — which modules you can enter
- ↳ 🧩 Module Access Control
- ↳ 🛠️ Module Access — Developer Guide
- ↳ 🔌 Switching modules off
- ↳ 🛠️ Module availability — Developer Guide
- ↳ 📐 Module availability — design (#217)
- 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
- ↳ 🛡️ CSRF protection (S4) — Developer Guide
- Single Sign-On (SSO)
- 🗂️ LDAP & Active Directory
- 📇 CardDAV contact sync
- Browser Extension
- API Reference
-
🔌 REST API — how it works
- ↳ 📋 Proposal: custom fields, inventory import and change evidence (#194)
- ↳ 🛠️ Custom fields, evidence + inventory - review (#195-#197)
- ↳ 🛠️ Long forms in a task's Work view - review (#198)
- ↳ 🎫 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: Domains
- ↳ 🚦 REST API: Service Status
- ↳ ☀️ REST API: Morning Checks
- ↳ 📝 REST API: Forms
- ↳ ⚙️ REST API: Workflow
- ↳ 🏷️ REST API: Cost centres
- ↳ 🧑💼 REST API: Analysts
- ↳ 🗺️ REST API: Network Mapper
- ↳ 🧭 Using the API docs page
- ↳ 📐 OpenAPI specification
- ↳ ✅ OpenAPI: kept correct
- ↳ 🛠️ Maintaining the catalogue
- Watchtower
-
Tickets
- ↳ 🛠️ Inbox.js map — Developer Guide
- ↳ 🛠️ Tickets settings — Developer Guide
- ↳ 🗂️ The ticket workspace
- ↳ 📊 The ticket table
- ↳ 📋 Rota copy and paste — Developer Deep Dive
- ↳ ✅ Checklists & SOPs
- ↳ ☑️ Mandatory fields
- ↳ 🧾 Custom ticket fields
- ↳ 🏷️ Ticket categories
- ↳ 👥 Assigning tickets to a team, and escalation
- ↳ 🏢 One board across every company
- ↳ Mailbox Authentication
- ↳ 📤 Email send log
- ↳ 📎 Attachment size limits and large emails
- ↳ Basic IMAP mailboxes
- ↳ Email rendering & images
- ↳ SLA Management
- ↳ WhatsApp channel
-
↳
✈️ Telegram channel - ↳ ⭐ CSAT company scope and filters — Developer Guide
- ↳ 👥 Microsoft Teams channel
- ↳ 🗨️ Mattermost channel
- ↳ 💬 Web chat channel
- ↳ 🟣 Slack channel
- ↳ 🔗 Linking tickets
- ↳ ⓘ Record previews
- ↳ 📝 Ticket notes: internal or shared
- ↳ 🗒️ Canned responses
- ↳ ✉️ Limiting replies to particular senders
- ↳ 📨 Telling the analyst a ticket is theirs
- ↳ ✍️ Email signatures
- ↳ 🌐 The public web address
- ↳ 🔢 Ticket numbering
- ↳ 🙋 Raising a ticket for someone else
- ↳ 🔀 Merging tickets
- ↳ 🔒 Confidential tickets
- ↳ 👥 Portal managers
- ↳ 👁 Who has seen a ticket
- ↳ 📜 Reading long tickets
- ↳ ⑂ Splitting tickets
- ↳ ✅ Selecting several tickets
- ↳ 🗂️ The folder pane
- ↳ 🔽 Just my tickets, or no closed ones
- ↳ 🛠️ Snoozing tickets — Developer Guide
- ↳ 👥 Collision detection
- ↳ ⏱️ Time tracking
- ↳ 📄 Case reports - a ticket and its work as one PDF
- ↳ 📅 Scheduled work in your own calendar
- Problem Management
- Tasks
- 🚀 Projects
-
Assets
- ↳ 🏢 Moving an asset between companies
- ↳ 📍 Shared asset locations
- ↳ 🧑💼 Assigning assets to analysts
- ↳ 📆 Warranty and lease alerts
- ↳ 📄 Leases
- ↳ 🔭 Saved table views
- ↳ 🖨️ Recording anything, and importing it
- ↳ 🧩 Asset type layouts
- ↳ 🕒 Inventory freshness
- ↳ 🔗 Linked configuration item
- ↳ 🔌 Connected assets
- ↳ 🔍 Asset review
- ↳ 🏷️ QR asset labels
- ↳ 📋 Who holds what, and handover documents
- ↳ 🖥️ The inventory agent (PowerShell)
- ↳ 🗄️ Proxmox VE servers
- ↳ ☁️ VMware Cloud Director servers
- ↳ 🪟 Hyper-V and Failover Clusters
- ↳ 🔗 Linking equipment to tickets
- ↳ ☑️ Follow-up tasks on a ticket
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
-
Forms
- ↳ 🎨 The form designer — Developer Guide
- ↳ 📐 Layout & the grid — Developer Guide
- ↳ 🗂️ Collections — grouping submissions
- ↳ 📄 Submissions as PDFs
- ↳ ⚡ What happens next — a form's own actions
- ↳ 🛠️ Sections & conditional logic — Developer Guide
- ↳ 🛠️ Lookup fields — Developer Guide
- ↳ 🛡️ Catalogue request approvals
- People
- Domains
- 🗂️ Files
- Contracts
- Service Status
- 🔔 Notifications
- 🚨 War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- 📡 Zabbix (monitoring)
- 💬 Microsoft Teams alerts
- 📞 3CX (phone system)
- System
-
Overview
- ↳ 📊 Progress tracker
- ↳ Concepts & vocabulary
- ↳ Email routing & mailboxes
- ↳ Settings: global vs per-company
- ↳ Users & self-service
- ↳ Staff cross-company access
- ↳ 🏢 One board across every company
- ↳ 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)
- What this is
-
🐞 Bugs resolved
- ↳ ⚙️ #214 A "contains" condition did not match capitals
- ↳ ⚙️ #216 A workflow email from a chosen mailbox always failed
- ↳ 🐳 The Docker image was missing PHP extensions
- ↳ 🌐 A failed DNS lookup read as a missing record
- ↳ 🔒 A link could change data
- ↳ 🔒 Access outlived its removal
- ↳ 🔒 Knowledge articles could run scripts
- ↳ 🔒 Knowledge writes ignored permissions
- ↳ 🔒 Company boundaries (Oct 2026)
- ↳ 🔒 Smaller gaps (Oct 2026)
- ↳ 🔒 Escaped text could leave its attribute
- ↳ 🔒 Task descriptions were not cleaned
- ↳ 🔒 Task documents ignored company scope
- ↳ 🔒 Repeating tasks ignored company scope
- ↳ 📬 #183 Mailbox checks overlapped
- ↳ 🌐 #188 Transfer lock shown off when unknown
- ↳ ✅ #184 Subtask names and Overdue
- ↳ 🧩 #186 Teams: the buttons did nothing
- ↳ 🔒 Text in a change could run script
- ↳ 🔒 AI settings changed without the AI permission
- ↳ 🚪 #178 Deleting an analyst failed
- ↳ 🎫 A team with no departments showed 0 tickets
- ↳ 🚀 Projects: adding a phase did nothing on Save
- ↳ 🔒 Leavers could still sign in
- ↳ 🖼️ Logo and courses broke on Apache with PHP-FPM
- ↳ 🔢 Chat tickets ignored your ticket numbering
- ↳ 📅 Dates shown as a dash, or in server time
- ↳ 🔒 Assets → Users showed people from other companies
- ↳ 🔒 Restricted analysts could read other modules' data
- ↳ 🖼️ Replies with a picture in the thread failed to send
- ↳ 📎 Reply attachments never reached the customer
- ↳ 🛠️ Outbound email attachments — Developer Guide
- ↳ 🔑 A global SSO provider was missing from the portal
- ↳ 🔀 Behind a proxy, the SSO redirect said http
- ↳ ✏️ The portal tagline moved when you saved it
- ↳ 🎨 The portal settings screen forgot what you saved
- ↳ 🛡️ The approvals inbox said "Error" and nothing else
- ↳ 📄 A table's answers were missing from the PDF
- ↳ ◉ A single-select column let you tick every option
- ↳ 📐 The portal ignored a form's field widths
- ↳ 📋 The tasks board stopped taking clicks
- ↳ 🗂️ #121 The index list is out of date after upgrading
- ↳ 📅 #133 The calendar subscription was empty
- ↳ 📋 #131 Tasks always reopened on the board
- ↳ 💥 #129 Every page returned HTTP 500 after upgrading
- ↳ 🐳 #127 A PHP warning above the System page
- ↳ 🕐 #126 Notes stamped with the server's clock
- ↳ 🌍 Storing every date in UTC
- ↳ 🚪 The portal was down for everyone signed in
- ↳ ⚙️ #120 Workflow notes could never be written
- ↳ ⚙️ #123 Three errors when running Database Verification
- ↳ 📝 #122 The description box was a stub in the corner
- ↳ 💣 Demo data deleted real accounts
- ↳ 🔐 #117 Sign-in redirected to the wrong address
- ↳ 🎨 #108 The priority dot was invisible
- ↳ ⏱️ #116 Time logged from the right-click menu
- ↳ 🔑 #114 API keys refused by our own guard
- ↳ 🗂️ #110 Assigning a task told nobody
- ↳ 🚪 #107 Signed out while still working
- ↳ 📎 #103 "Share with Requester" reached nobody
- ↳ 🔍 #102 Search found nothing for hyphens
- ↳ 🪟 #101 Source code editor opened behind
- ↳ ☑️ #88 Subtasks could not be ticked off
- ↳ 💻 #84 Asset deep link selected nothing
- ↳ 🎫 #79 A new ticket arrived with no status
- ↳ 📧 #79 A ticket from email did not say so
- ↳ 🔔 #78 Bell opened to nothing
- ↳ 📬 #77 Mail only collected from Inbox
- ↳ 🔐 #74 The default password could not be changed
- ↳ 🚦 #70 Renaming an impact level
- ↳ 📤 #67 App-only mailboxes could not send
- ↳ 📭 #45 Verify only ever worked for Microsoft
- ↳ 📭 #45 IMAP reported as not authenticated
- ↳ ✉️ An email template stopped escaping itself
- ↳ 🕐 The portal dashboard showed the wrong time
- ↳ 🔢 The folder said 99 and the list showed 96