-
-
Notifications
You must be signed in to change notification settings - Fork 28
Rota Copy And Paste Developer Deep Dive
How the staff rota's copy, paste and clear gestures are built: the one database key that makes pasting trivial, why a column cannot be pasted onto a row, how a drag-select coexists with a click that already meant something, and the handful of traps that cost a wrong test run each.
The user-facing description is in Tickets β Staff Rota. This page is the why.
Shipped: 1.9.0 (fc5c247d, cell and week) and 2.0.0 (41844e1d, 9e59c2dc β many cells, column, row, clear).
The rota is a grid: analysts down the side, days across the top, one shift per cell.
Mon Tue Wed Thu Fri
James [Early] [Early] [ + ] [Late ] [ + ]
Laura [Late ] [ + ] [Early] [Early] [Early]
Raj [ + ] [On-call] β¦
Four gestures put shifts into it, and they are not four versions of one operation β they differ in what is on the clipboard and in what "paste" means:
| Gesture | Clipboard holds | Paste means |
|---|---|---|
| A cell | one shift | write it here |
| A selection / column / row, with a cell copied | one shift | stamp it into every target cell |
| A column (one day, everybody) | a shift per analyst | replace that day |
| A row (one analyst, all week) | a shift per day | replace that analyst's week |
| A week | a shift per (analyst, day) | replace that week |
Stamp and replace are different enough to be different endpoints. Conflating them was tempting and would have been wrong: a stamp never removes anything, a replace must.
CREATE TABLE IF NOT EXISTS `ticket_rota_entries` (
`id` INT NOT NULL AUTO_INCREMENT,
`analyst_id` INT NOT NULL,
`rota_date` DATE NOT NULL,
`shift_id` INT NOT NULL,
β¦
UNIQUE KEY `uq_analyst_date` (`analyst_id`, `rota_date`),A cell is a (analyst_id, rota_date) pair, and the table says so with a unique key. Everything else follows:
INSERT INTO ticket_rota_entries
(analyst_id, rota_date, shift_id, location_id, is_on_call, created_datetime, updated_datetime)
VALUES (?, ?, ?, ?, ?, UTC_TIMESTAMP(), UTC_TIMESTAMP())
ON DUPLICATE KEY UPDATE
shift_id = VALUES(shift_id),
location_id = VALUES(location_id),
is_on_call = VALUES(is_on_call),
updated_datetime = UTC_TIMESTAMP()Pasting onto an empty cell and pasting over a full one are the same statement. No "does a row already exist here" round trip, no branch, no race between the check and the write. Every paste path in the rota β single cell, stamp, line, week β uses that one statement.
Tip
This is why a cell copies the shift, never the entry id. The id belongs to the row you copied from; pasting writes a different (analyst, date) and must not touch the source. Carrying the id would have made that mistake available.
This is the central design idea and the one everything else falls out of.
A clipboard entry has to be re-addressed when it lands somewhere else. Whatever is going to change in the move cannot be part of what you store.
// A COLUMN is one day across the team. The day changes; the person does not.
base.analyst_id = entry.analyst_id;
// A ROW is one analyst across the week. The person changes; the day does not.
base.day_offset = Math.round(
(new Date(tg.rota_date + 'T00:00:00') - new Date(currentWeekStart + 'T00:00:00')) / 86400000);| Clipboard | Keyed by | Free to change on paste |
|---|---|---|
| cell | (nothing β one shift) | analyst and date |
| column | analyst_id |
the date |
| row | day_offset |
the analyst |
| week |
analyst_id + day_offset
|
the week |
The week clipboard has stored a day_offset rather than a date since 1.9.0 for exactly this reason β the whole point is landing it on a different week, so the date is the part that cannot survive.
Not a UI restriction bolted on afterwards. A column's entries are keyed by analyst and a row's by day offset β they are not the same kind of thing. There is no honest mapping from "a shift for each of seven people" onto "a shift for each of five days", and inventing a plausible one would be worse than refusing.
So the menu refuses, and says where it can go:
if (rotaLineClipboard.kind !== kind) {
rotaSetPasteBlocked(
kind === 'col' ? t('β¦paste_row_onto_col') : t('β¦paste_col_onto_row'),
kind === 'col' ? t('β¦paste_row_onto_col_why') : t('β¦paste_col_onto_row_why'));
return;
}You copied a whole day, which is one shift per analyst. It can only be pasted onto another day heading.
The most dangerous thing in this feature, and the reason every endpoint looks paranoid.
Two categories of row exist in ticket_rota_entries and are not drawn on the grid:
-
Entries belonging to a deactivated analyst.
get_rota.phplists analystsWHERE is_active = 1. Their rows are still there. -
Weekend entries when
rota_include_weekendsis off. The grid draws Monday to Friday; Saturday and Sunday rows exist and simply are not rendered.
A "replace this week" or "clear this column" scoped by a date range destroys both. The user would have no way of knowing, because they were never on screen.
Every scope is derived server-side, in the endpoint, and never taken from the request:
$activeIds = array_map('intval',
$conn->query("SELECT id FROM analysts WHERE is_active = 1")->fetchAll(PDO::FETCH_COLUMN));
$inList = implode(',', $activeIds);
$numDays = ($setting !== false ? (int)$setting : 0) ? 7 : 5;
$lastDay = (new DateTime($weekStart))->modify('+' . ($numDays - 1) . ' days');
$del = $conn->prepare(
"DELETE FROM ticket_rota_entries
WHERE rota_date BETWEEN ? AND ?
AND analyst_id IN ($inList)"
);The client already knows both sets. It is not asked. A delete scoped by whatever the caller sent is a delete scoped by whatever the caller sent.
Note the row-paste case is subtler: it deletes one analyst's week, and the range end is weekStart + numDays - 1, so with weekends off the range is MonβFri and Saturday falls outside it naturally rather than by a special case.
"The Saturday survived" is worthless on its own β a request that did nothing at all also leaves Saturdays alone. Every refusal is tested with a write that must succeed in the same request:
$r = post('paste_rota_cells.php', ['β¦' => β¦, 'targets' => [
['analyst_id' => $a, 'rota_date' => SAT], // must be refused
['analyst_id' => $a, 'rota_date' => TUE], // POSITIVE CONTROL: must be written
]]);
ck('the Tuesday WAS written (so the refusal below is not a blanket one)', ($r['written'] ?? -1) === 1);
ck('the Saturday was refused', ($r['skipped_invalid'] ?? -1) === 1);A week is seven days times however many analysts you have. Firing save_rota_entry.php per cell would leave a half-pasted week behind the first failure, and the person would have no idea which half.
Every multi-cell operation is one transactional request:
| Endpoint | Job |
|---|---|
api/tickets/paste_rota_cells.php |
stamp one shift into many cells |
api/tickets/paste_rota_line.php |
replace a column or a row |
api/tickets/clear_rota_cells.php |
empty many cells |
api/tickets/paste_rota_week.php |
replace a week |
The menu offers Paste into all 7 and Paste into 6 empty cells. The obvious implementation is to let the browser send only the empty ones β it already knows which they are, it drew them.
That is wrong, and it is wrong in exactly the case the option exists for. The grid may be seconds old. If a colleague filled one of those cells while you were reading the menu, "don't overwrite anything" has to mean nothing that is there now, not nothing that was there when the page last loaded.
So the client sends every target plus a mode, and the server re-reads:
$conn->beginTransaction();
$occupied = [];
if ($mode === 'empty') {
$analystList = array_values(array_unique(array_column($valid, 'analyst_id')));
$dateList = array_values(array_unique(array_column($valid, 'rota_date')));
$sql = "SELECT analyst_id, rota_date FROM ticket_rota_entries
WHERE analyst_id IN (" . implode(',', array_map('intval', $analystList)) . ")
AND rota_date IN (" . implode(',', array_fill(0, count($dateList), '?')) . ")";
$occStmt = $conn->prepare($sql);
$occStmt->execute($dateList);
foreach ($occStmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$occupied[$row['analyst_id'] . '|' . $row['rota_date']] = true;
}
}Two details worth copying:
-
It is a cross-product superset, then a key filter. Asking for the exact pairs would mean
(a=? AND d=?) OR (a=? AND d=?) OR β¦β 35 clauses and 70 parameters. Asking for all analysts Γ all dates in the set is twoINlists, and the"$analyst|$date"map filters it down. The superset is at most the size of the grid. -
Integers are interpolated, dates are bound.
$analystListisarray_map('intval', β¦)so anINlist built byimplodecannot carry anything but digits; the dates come from user input, so they get placeholders. Mixing the two styles in one statement is deliberate, not sloppy.
Every endpoint judges the whole request before writing any of it:
$valid = [];
$skippedInvalid = 0;
foreach ($targets as $tgt) {
β¦
if (!isset($activeIds[$analystId]) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { $skippedInvalid++; continue; }
$d = DateTime::createFromFormat('Y-m-d', $date);
if (!$d || $d->format('Y-m-d') !== $date) { $skippedInvalid++; continue; }
if (!$includeWeekends && (int)$d->format('N') > 5) { $skippedInvalid++; continue; }
$valid[$analystId . '|' . $date] = ['analyst_id' => $analystId, 'rota_date' => $date];
}Three things fall out of that shape:
-
The counts reported back are the whole truth.
written,skipped_filledandskipped_invalidare known before the first write, so the toast cannot claim more than happened. -
Keying
$validby"$analyst|$date"de-duplicates for free, so a client that sends a cell twice writes it once. -
DateTime::createFromFormatplus the round-trip comparison. The regex alone accepts2027-02-31;createFromFormathappily rolls it to 3 March; only$d->format('Y-m-d') !== $datecatches it.
Whitelists are array_flipped so membership is an isset rather than an in_array scan:
$validShifts = array_flip(array_map('intval',
$conn->query("SELECT id FROM ticket_rota_shifts")->fetchAll(PDO::FETCH_COLUMN)));And a shift or location retired between the copy and the paste is counted and reported, never dropped silently β a paste that quietly loses somebody's shift is the worst thing this feature could do.
A plain click on a rota cell opens the shift editor. It always has, and it is the commoner action. Selecting cells cannot take that away.
The rule: selecting is a drag across cells, or a ctrl/cmd-click. A press and release inside one cell is never a selection.
loadRota() replaces grid.innerHTML wholesale on every load. Any listener bound to a cell dies with it. Every handler is bound once, to #rotaGrid, which survives:
(function wireRotaSelection() {
const grid = document.getElementById('rotaGrid');
if (!grid) return;
grid.addEventListener('mousedown', function (e) {
rotaSuppressClick = false;
if (e.button !== 0) return;
const cell = e.target.closest('.rota-cell');
if (!cell) return;
if (e.ctrlKey || e.metaKey) {
const key = rotaCellKey(cell.dataset.analyst, cell.dataset.date);
if (rotaSelection.has(key)) rotaSelection.delete(key);
else rotaSelection.add(key);
applyRotaSelection();
rotaSuppressClick = true;
e.preventDefault();
return;
}
rotaDragAnchor = cell;
rotaDragMoved = false;
}); grid.addEventListener('mouseover', function (e) {
rotaHoverSet(e.target.closest('.rota-line-head'));
if (!rotaDragAnchor) return;
const cell = e.target.closest('.rota-cell');
if (!cell || cell === rotaDragAnchor) return; // β the whole rule
rotaDragMoved = true;
rotaSelectRect(rotaDragAnchor, cell);
});cell === rotaDragAnchor is what keeps a click a click. Mouse jitter inside one cell never sets rotaDragMoved, so the click that follows opens the editor exactly as before.
document.addEventListener('mouseup', function () {
if (rotaDragMoved) rotaSuppressClick = true;
rotaDragAnchor = null;
rotaDragMoved = false;
});Bound to the grid, a drag that runs off the edge of the table never ends and the next mouse move keeps extending the selection.
The cells carry an inline onclick="openRotaEntryModal(β¦)". A listener on the grid in the bubble phase runs after that β too late. In the capture phase it runs before the event reaches the target, and stopPropagation() there means it never arrives:
grid.addEventListener('click', function (e) {
if (rotaSuppressClick) {
rotaSuppressClick = false;
e.stopPropagation();
e.preventDefault();
return;
}
if (e.target.closest('.rota-cell')) clearRotaSelection();
}, true); // β capture
})();Warning
rotaSuppressClick = false on every mousedown is load-bearing. A drag that ends outside the grid sets the flag from the document-level mouseup, and no click ever fires on the grid to consume it. Without the reset, the flag survives and eats the next legitimate click β a cell that mysteriously refuses to open, once, after a sloppy drag.
Cells carry data-row and data-col purely so a selection is min/max arithmetic rather than DOM walking:
function rotaSelectRect(from, to) {
const r1 = Math.min(+from.dataset.row, +to.dataset.row);
const r2 = Math.max(+from.dataset.row, +to.dataset.row);
const c1 = Math.min(+from.dataset.col, +to.dataset.col);
const c2 = Math.max(+from.dataset.col, +to.dataset.col);
rotaSelection.clear();
document.querySelectorAll('#rotaGrid .rota-cell').forEach(cell => {
const r = +cell.dataset.row, c = +cell.dataset.col;
if (r >= r1 && r <= r2 && c >= c1 && c <= c2) {
rotaSelection.add(rotaCellKey(cell.dataset.analyst, cell.dataset.date));
}
});
applyRotaSelection();
}The selection is a Set of "analystId|YYYY-MM-DD" strings rather than a list of elements β so it survives loadRota() replacing every element, and applyRotaSelection() is called at the end of renderRotaGrid() to re-mark it.
And because dragging across a table otherwise drag-selects its text, leaving the grid streaked blue under our own highlight:
.rota-grid { user-select: none; -webkit-user-select: none; }The trick is in the markup, not the JavaScript: a day heading carries the same data-date its cells carry, and an analyst's name cell the same data-analyst.
function rotaLineCells(head) {
const sel = head.dataset.date
? `.rota-cell[data-date="${head.dataset.date}"]`
: `.rota-cell[data-analyst="${head.dataset.analyst}"]`;
return Array.from(document.querySelectorAll('#rotaGrid ' + sel));
}One attribute selector gets the whole line, for either orientation, with no index arithmetic β and the same call produces the paste targets. Highlighting and targeting cannot disagree, because they are the same function.
rotaHoverSet() is idempotent via a rotaHoverKey guard, so the mouseover firing continuously over one heading does no work after the first.
Opening the context menu moves the pointer off the grid onto the menu, which is a sibling element β so mouseleave fires and the highlight you need to see vanishes at the moment you need it:
grid.addEventListener('mouseleave', function () {
if (!rotaCtxLine) rotaHoverClear();
});rotaCtxLine is set before rotaHoverSet(head) in openRotaLineMenu(), precisely so the guard is already true by the time the pointer leaves.
base.day_offset = Math.round(
(new Date(tg.rota_date + 'T00:00:00') - new Date(currentWeekStart + 'T00:00:00')) / 86400000);+ 'T00:00:00' is not decoration. new Date('2027-06-09') is parsed by the spec as UTC midnight; new Date('2027-06-09T00:00:00') is parsed as local midnight. Rota dates are wall-clock dates with no time in them, so the bare form shifts the whole grid by a day for anybody west of UTC.
Math.round, not Math.floor or integer division. Across a daylight-saving boundary two local midnights are 23 or 25 hours apart, so the difference is not an exact multiple of 86400000. floor would return 4 for a five-day span in the week the clocks change. round gives the day count people mean.
When a paste is refused, the item is dimmed and carries a title explaining why. It is deliberately not disabled:
function rotaSetPasteBlocked(label, why) {
const pasteBtn = document.getElementById('rotaCtxPaste');
rotaPasteBlockedReason = why;
pasteBtn.disabled = false; // β on purpose
pasteBtn.style.opacity = '0.55';
pasteBtn.title = why;
document.getElementById('rotaCtxPasteLabel').textContent = label;
rotaHidePasteEmpty();
}A disabled button does not fire mouse events in several browsers, so the tooltip β the one thing that explains the refusal β is exactly what you cannot see. The click is refused in the handler instead, and repeats the reason as a toast for anyone who missed the hover:
if ((action === 'paste' || action === 'paste_empty') && blocked) {
showToast(blocked, 'error');
return;
}function rotaSetClipboard(cell, line) {
rotaCellClipboard = cell;
rotaLineClipboard = line;
}A cell holds one shift; a line holds a different shift in every cell. They paste onto different things, so a menu offering both at once would make the reader work out which Paste is which. Copying either puts the other down β the way a clipboard actually behaves.
The week clipboard is deliberately excluded. It lives on its own toolbar button rather than in this menu, and copying a cell should not silently bin a week you set up three screens ago.
All of them are in-memory only (top-level let), never sessionStorage: a clipboard that outlives the tab means opening the rota tomorrow with a half-remembered week loaded and a Paste button offering to apply it.
Four traps, each of which cost a wrong run.
1. Top-level let is not a window property. rotaCellClipboard, rotaSelection and rotaCtxLine are declared at the top level of a classic script, which puts them in the global lexical environment β window.rotaCellClipboard is undefined while the feature works perfectly. Setting it from a harness creates an unrelated property and the paste silently does nothing.
β To read one, indirect eval runs in global scope and can see them:
const clip = iframe.contentWindow.eval('rotaLineClipboard');β
To drive the feature, dispatch real events and call real .click()s β never poke state.
2. loadRota() detaches every element reference you are holding. A harness that grabbed heads[0] before a reload is dispatching events at an orphan: they never reach the grid's delegated listeners, and every hover assertion reads zero while the page is fine. Re-query after every reload.
3. --dump-dom fires before the async work finishes β but the test still ran. A headless run that printed one line had already executed every step and left its fixtures behind, so the next run failed on dirty state. Clean the fixture before each run, and ship results out with navigator.sendBeacon to a temporary receiver rather than reading them out of the dumped DOM.
4. A logged-out headless load serves the login page and reports no console errors. The harness is a temporary PHP page in the app root that sets the session and then iframes the real page, so the iframe inherits the cookie:
session_start();
$_SESSION['analyst_id'] = 1;
?>
<iframe id="f" src="tickets/rota.php"></iframe>Caution
The development database is real data. Snapshot ticket_rota_entries to JSON first, work only in a far-future empty week (2027-06-07), delete it after, and compare the whole table field-for-field. created_datetime before assuming a diff is yours β two rows appeared mid-session during this work that turned out to be Ed testing the feature live.
| File | Role |
|---|---|
tickets/rota.php |
the grid page, the entry modal, the shared .ticket-context-menu
|
assets/js/rota.js |
everything above β rendering, selection, clipboards, menus |
assets/css/rota.css |
grid, .selected, .line-hover, user-select
|
api/tickets/get_rota.php |
π defines what the grid can see β active analysts, the weekend setting |
api/tickets/save_rota_entry.php |
one cell, upsert |
api/tickets/paste_rota_cells.php |
one shift β many cells, all or empty
|
api/tickets/paste_rota_line.php |
a column or a row, replaces |
api/tickets/clear_rota_cells.php |
empty many cells |
api/tickets/paste_rota_week.php |
a week, replaces |
- Ask what the key is. Any new clipboard kind needs to know which part of the address survives the move, and that decides what it can be pasted onto.
-
Derive the scope in the endpoint.
get_rota.phpis the definition of "what the user can see"; anything that deletes must agree with it, from its own query. - Pair every refusal with a positive control in the same request, or the test proves nothing.
- Never take "which cells are empty" from the browser.
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- π§ͺ Developer tests
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- 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: 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
- 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
- β³ π« 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: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ π Rota copy and paste β Developer Deep Dive
- β³ β Checklists & SOPs
- β³ βοΈ Mandatory fields
- β³ π·οΈ Ticket categories
- β³ π₯ Assigning tickets to a team, and escalation
- β³ π’ One board across every company
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ 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
- β³ π Scheduled work in your own calendar
- Problem Management
- Tasks
-
Assets
- β³ π’ Moving an asset between companies
- β³ π Shared asset locations
- β³ π§βπΌ Assigning assets to analysts
- β³ π Warranty and lease alerts
- β³ π Saved table views
- β³ π¨οΈ Recording anything, and importing it
- β³ π·οΈ QR asset labels
- β³ π Who holds what, and handover documents
- β³ π₯οΈ The inventory agent (PowerShell)
- β³ ποΈ Proxmox VE servers
- β³ βοΈ VMware Cloud Director servers
- β³ π 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
- 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
- β³ π’ 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
- β³ π’ 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