Repository navigation
Forms on Tickets Developer Guide
How a form from the Forms module is attached to a ticket, filled in on it, linked to it, and required before it closes. Built for #272 (5.0.0). User guide: Forms on tickets.
-
Page 1 (this page): the files, the schema, how one engine serves tasks and tickets (
TaskForms/TicketForms), submitting and the retry key, the Forms evidence guard, the close gate, merge, delete and the analyst lifecycle. - Page 2: the browser endpoint, the fill page, the ticket's Forms panel, the workspace Work tab, right-click Add form, the setting, the case report, the REST API and the tests.
The one-line version: forms on tickets are forms on tasks with a different host. TaskForms (includes/services/task_forms.php, built for #190) names its host only through class constants and hooks, and TicketForms extends TaskForms overrides just those. One engine, one set of rules: newest version, the shared draft with its revision check, the retry key, corrections that keep the old submission, the answers rule, the company scope. Nothing was copied.
ποΈ schema Β· βοΈ service Β· π endpoint Β· π₯οΈ UI Β· π cross-module Β· π§ͺ tests
| File | π¨ | What happens there |
|---|---|---|
database/freeitsm.sql, includes/db_verify_schema.php, includes/db_verify_indexes.php (generated) |
ποΈ |
ticket_forms; form_submissions.for_ticket_id with its own index and retry-key unique index |
includes/services/task_forms.php |
βοΈ |
TaskForms made host-aware; class TicketForms extends TaskForms
|
includes/services/forms.php |
π |
submitForm(β¦, $link) writes for_ticket_id; assertNotTaskEvidence() refuses deleting what a ticket relies on (ticket_evidence) |
includes/services/checklists.php |
π |
assertClosureAllowed() checks outstanding ticket forms first; recordClosureOverride() records warn-mode ones |
includes/capabilities.php, tickets/settings/manifest.php
|
π |
Cap::TICKETS_FORM_ANSWERS; the tickets_forms_enabled key on the Checklists tab |
includes/ticket_merge.php |
π | MERGE_MOVE_DEDUPE['ticket_forms'] = 'form_id' |
api/tickets/permanently_delete_ticket.php, empty_trash.php, api/system/debug-tools/D002_delete_ticket.php
|
π | delete the requirement rows, keep the submissions |
includes/analyst_lifecycle.php |
π | ['ticket_forms', 'added_by_id', 'history'] |
api/system/db_verify.php |
π | seeds tickets_forms_enabled = '0'
|
api/tickets/forms.php |
π | page 2 |
forms/fill.php |
π₯οΈ | page 2 |
assets/js/ticket-forms.js, assets/js/inbox.js, tickets/index.php
|
π₯οΈ | page 2 |
tickets/settings/index.php, api/tickets/save_checklist_settings.php
|
π₯οΈ | page 2 |
includes/case_report.php, includes/case_report_page.php
|
π | page 2 |
api/v1/resources/tickets.php, lib/routes.php, lib/permissions.php, lib/modules.php, spec.json
|
π | page 2 |
tests/ticket-forms.php |
π§ͺ | page 2 |
A column-for-column twin of task_forms, so the engine's SQL works on either table by name:
CREATE TABLE IF NOT EXISTS `ticket_forms` (
`id` INT NOT NULL AUTO_INCREMENT,
`ticket_id` INT NOT NULL,
`form_id` INT NOT NULL, -- the exact form VERSION this ticket is filled on
`title` VARCHAR(255) NOT NULL,
`is_required` TINYINT(1) NOT NULL DEFAULT 1,
`requirement_mode` ENUM('warn','block') NOT NULL DEFAULT 'block',
`submission_id` INT NULL,
`draft_json` LONGTEXT NULL,
`draft_by_id` INT NULL,
`draft_by_name` VARCHAR(150) NULL,
`draft_datetime` DATETIME NULL,
`revision` INT NOT NULL DEFAULT 0,
`sort_order` INT NOT NULL DEFAULT 0,
`added_by_id` INT NULL,
`added_datetime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_ticket_forms_form` (`ticket_id`, `form_id`),
KEY `idx_ticket_forms_submission` (`submission_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;| Column | Meaning |
|---|---|
form_id |
the exact form version it is filled on: add() resolves the newest version in the chain |
title |
the form's title when added, kept if the form is later renamed |
submission_id |
the submission that currently meets it; a correction replaces it |
draft_json, draft_by_*
|
one shared draft per ticket form, not one per person, so a colleague can carry on |
revision |
bumped by every draft save and submit; a write that names a stale revision is a conflict |
UNIQUE (ticket_id, form_id) |
the same form once per ticket |
π΄ No foreign keys, as for task_forms. A cascade would delete the evidence along with the thing it is evidence about.
-- GH #272: a submission made FOR a ticket (a form on the ticket - ticket_forms), with the
-- ticket's company in tenant_id as for a task. NOT ticket_id, which means "the ticket
-- raised FROM this submission" (a catalogue request).
`for_ticket_id` INT NULL,
β¦
KEY `idx_form_submissions_for_ticket` (`for_ticket_id`),
UNIQUE KEY `uq_form_submissions_task_ref` (`task_id`, `client_reference`),
UNIQUE KEY `uq_form_submissions_ticket_ref` (`for_ticket_id`, `client_reference`),-
Why not
ticket_id.form_submissions.ticket_idalready means "the ticket this submission raised" (a catalogue request becomes a ticket). Reusing it would make every such ticket look like it had a form on it, and the reverse. -
Its own retry key.
client_referenceis unique per ticket, so a task and a ticket can never collide on the same reference. -
tenant_idis the ticket's company at submit time, so the Forms module can scope the submission (forms themselves are install-wide).
includes/db_verify_schema.php has the same columns, so Database Verify creates them on existing installs. The indexes come from php scripts/gen_db_verify_indexes.php.
Before #272, TaskForms hard-coded task_forms, task_id, tasks_forms_enabled and the task permission. Now every host-specific piece is a constant or a protected static hook, and every method reaches them through static::, which is PHP's late static binding. So a method defined once in TaskForms runs with TicketForms's values when it is called as TicketForms::β¦.
class TaskForms
{
/** The requirement table. */
protected const TABLE = 'task_forms';
/** Its column naming the host. */
protected const FK = 'task_id';
/** The column on form_submissions linking a submission to its host. */
protected const SUB_COL = 'task_id';
/** The system_settings switch. */
protected const SETTING = 'tasks_forms_enabled';
/** The host's name in messages. */
protected const NOUN = 'task';
protected static function answersCap(): ?Cap { return class_exists('Cap') ? Cap::TASKS_FORM_ANSWERS : null; }
protected static function hostTenant(PDO $conn, int $hostId): ?int { /* SELECT tenant_id FROM tasks β¦ */ }
protected static function hostSummary(PDO $conn, int $hostId): array { /* id, title, scheduled_date, due_date */ }
protected static function record(PDO $conn, int $hostId, string $type, ActorContext $ctx, ?string $from, ?string $to, array $detail): void
{
TaskHistory::record($conn, $hostId, $type, $ctx, $from, $to, $detail); // task_events
}
protected static function afterSubmit(PDO $conn, ActorContext $ctx, int $hostId, int $submissionId): void { /* Starters & leavers */ }
protected static function guardWrite(PDO $conn, ActorContext $ctx, int $taskId): void { /* Tasks write, task visible, #181 lock */ }
// β¦ every public method below uses static::TABLE, static::FK, static::SUB_COL, static::guardWrite() β¦
}class TicketForms extends TaskForms
{
protected const TABLE = 'ticket_forms';
protected const FK = 'ticket_id';
protected const SUB_COL = 'for_ticket_id';
protected const SETTING = 'tickets_forms_enabled';
protected const NOUN = 'ticket';
protected static function answersCap(): ?Cap
{
return class_exists('Cap') && defined('Cap::TICKETS_FORM_ANSWERS') ? Cap::TICKETS_FORM_ANSWERS : null;
}
protected static function hostTenant(PDO $conn, int $hostId): ?int
{
$t = $conn->prepare("SELECT tenant_id FROM tickets WHERE id = ?");
$t->execute([$hostId]);
$v = $t->fetchColumn();
return $v !== false && $v !== null ? (int)$v : null;
}
protected static function hostSummary(PDO $conn, int $hostId): array
{
$t = $conn->prepare("SELECT id, ticket_number, subject FROM tickets WHERE id = ?");
$t->execute([$hostId]);
$r = $t->fetch(PDO::FETCH_ASSOC) ?: [];
$num = (string)($r['ticket_number'] ?? '');
return ['id' => (int)($r['id'] ?? 0), 'title' => trim(($num !== '' ? $num . ' ' : '') . (string)($r['subject'] ?? '')),
'scheduled_date' => null, 'due_date' => null];
}
/** A ticket's history is ticket_audit: "Form added: Laptop handover". NULL analyst = no person. */
protected static function record(PDO $conn, int $hostId, string $type, ActorContext $ctx, ?string $from, ?string $to, array $detail): void
{
$label = ['form_added' => 'Form added', 'form_removed' => 'Form removed',
'form_submitted' => 'Form submitted', 'form_replaced' => 'Form resubmitted'][$type] ?? 'Form';
try {
$conn->prepare("INSERT INTO ticket_audit (ticket_id, analyst_id, field_name, old_value, new_value, created_datetime)
VALUES (?, ?, ?, ?, ?, UTC_TIMESTAMP())")
->execute([$hostId, $ctx->actorId > 0 ? $ctx->actorId : null, $label,
$type === 'form_removed' ? $from : null, $type === 'form_removed' ? null : $to]);
} catch (Throwable $e) {
error_log('[ticket forms] history for ticket ' . $hostId . ': ' . $e->getMessage());
}
}
protected static function afterSubmit(PDO $conn, ActorContext $ctx, int $hostId, int $submissionId): void
{
try { $conn->prepare("UPDATE tickets SET updated_datetime = UTC_TIMESTAMP() WHERE id = ?")->execute([$hostId]); }
catch (Throwable) { /* nothing else waits on it */ }
}
/** Tickets write access and the ticket visible (and there). */
protected static function guardWrite(PDO $conn, ActorContext $ctx, int $ticketId): void
{
if ($ctx->actorId > 0 && function_exists('analystCanWriteModule') && !analystCanWriteModule($conn, $ctx->actorId, 'tickets')) {
throw new ServiceError('forbidden', 'read_only', 'Read only access to Tickets cannot change a ticket\'s forms.');
}
$s = $conn->prepare("SELECT 1 FROM tickets WHERE id = ?");
$s->execute([$ticketId]);
if (!$s->fetchColumn() || ($ctx->actorId > 0 && function_exists('analystCanAccessTicket') && !analystCanAccessTicket($conn, $ctx->actorId, $ticketId))) {
throw new ServiceError('not_found', 'not_found', 'Ticket not found.');
}
}
public static function forTicket(PDO $conn, int $ticketId, int $viewerId, bool $withAnswers = false): array
{
return static::forHost($conn, $ticketId, $viewerId, $withAnswers);
}
}| Hook | Task | Ticket |
|---|---|---|
answersCap() |
Cap::TASKS_FORM_ANSWERS |
Cap::TICKETS_FORM_ANSWERS, so the task permission does not reveal ticket answers |
record() |
task_events via TaskHistory
|
a ticket_audit row: Form added / removed / submitted / resubmitted
|
afterSubmit() |
Starters & leavers picks up a case's answers | stamps tickets.updated_datetime, so live updates see it |
guardWrite() |
Tasks write access, the task visible, the #181 completed-task lock | Tickets write access, and the ticket exists and is visible |
-
Why
guardWrite()reads the row first.analystCanAccessTicket()is a company check that is true for any id on a single-company install. That was found in #277 and is reused here. -
Renamed for the generic case:
forTask()is kept as an alias offorHost(), anddeleteForTasks()ofdeleteForHosts(), so every existing caller of the task names still works.submissionScopeSql()scopes both kinds of linked submission (task_idandfor_ticket_id) by company in the Forms module's lists.
π΄ Never copy a method into
TicketForms. Change it inTaskForms, for both hosts. The class comment says so for the next person.
submit() is inherited unchanged. With TicketForms, static::SUB_COL is for_ticket_id:
public static function submit(PDO $conn, ActorContext $ctx, int $hostFormId, int $revision, string $clientRef, array $data): array
{
require_once __DIR__ . '/forms.php';
$r = static::row($conn, $hostFormId);
$hostId = static::hostOf($r);
$clientRef = substr(preg_replace('/[^A-Za-z0-9_.:-]/', '', $clientRef), 0, 64);
// A retry of a submit that DID go through: hand back what it made.
if ($clientRef !== '') {
$q = $conn->prepare("SELECT id FROM form_submissions WHERE " . static::SUB_COL . " = ? AND client_reference = ?");
$q->execute([$hostId, $clientRef]);
$existing = $q->fetchColumn();
if ($existing) {
return ['submission_id' => (int)$existing, 'revision' => (int)$r['revision'], 'replayed' => true];
}
}
static::guardWrite($conn, $ctx, $hostId);
static::guardFill($conn, $ctx);
$previous = $r['submission_id'] !== null ? (int)$r['submission_id'] : null;
$table = static::TABLE;
$within = function (PDO $conn, int $submissionId) use ($hostFormId, $revision, $previous, $table) {
$up = $conn->prepare("UPDATE $table
SET submission_id = ?, draft_json = NULL, draft_by_id = NULL, draft_by_name = NULL,
draft_datetime = NULL, revision = revision + 1
WHERE id = ? AND revision = ?");
$up->execute([$submissionId, $hostFormId, $revision]);
if ($up->rowCount() !== 1) {
throw static::conflict($conn, $hostFormId); // rolls the submission back with it
}
if ($previous) {
$conn->prepare("UPDATE form_submissions SET superseded_by_id = ? WHERE id = ?")->execute([$submissionId, $previous]);
}
};
$submissionId = FormsService::submitForm($conn, $ctx, (int)$r['form_id'], $data, null, [
static::SUB_COL => $hostId,
'tenant_id' => static::hostTenant($conn, $hostId),
'client_reference' => $clientRef !== '' ? $clientRef : null,
'within' => $within,
]);
static::record($conn, $hostId, $previous ? 'form_replaced' : 'form_submitted', $ctx, /* β¦ */);
static::afterSubmit($conn, $ctx, $hostId, $submissionId);
return ['submission_id' => $submissionId, 'revision' => $revision + 1, 'replayed' => false];
}-
The retry key. A second submit with the same
client_referencereturns the submission already made, withreplayed: true. The fill page and the REST API both send one, so a double click or a network retry makes one submission, not two. -
One transaction.
FormsService::submitForm()validates the answers exactly as the Forms page does, inserts the submission, and callswithininside its own transaction. -
The revision check.
withinclaims the requirement only if itsrevisionis still the one the person was shown. If someone else saved meanwhile, it throwsTaskFormConflict, which rolls the submission back too: a lost race leaves no orphan submission. The conflict carries who saved, when, and their answers, so the fill page can show them. -
A correction. The new submission replaces the old on the requirement, and the old gets
superseded_by_id. Both stay in Forms and keepfor_ticket_id.
The link inside submitForm():
// includes/services/forms.php - submitForm()
if (!empty($link['task_id'])) {
$cols .= ', task_id, tenant_id, client_reference';
$vals .= ', ?, ?, ?';
array_push($args, (int)$link['task_id'], $link['tenant_id'] ?? null, $link['client_reference'] ?? null);
} elseif (!empty($link['for_ticket_id'])) {
// GH #272: made FOR a ticket (a form on it) - never ticket_id, which is "raised from".
$cols .= ', for_ticket_id, tenant_id, client_reference';
$vals .= ', ?, ?, ?';
array_push($args, (int)$link['for_ticket_id'], $link['tenant_id'] ?? null, $link['client_reference'] ?? null);
}
$conn->prepare("INSERT INTO form_submissions ($cols) VALUES ($vals)")->execute($args);The form.submitted workflow payload gains for_ticket: {id} when there is one, so a workflow can tell a ticket's form apart from a public submission.
A submission a ticket relies on, or a form a ticket still needs, cannot be deleted from the Forms module. assertNotTaskEvidence() (called from deleteSubmission() and deleteForm()) gained a ticket half:
// GH #272: the same for a ticket's forms - a submission made for a ticket is its
// record, and a form a ticket needs cannot vanish from under it. Its own try: an
// install that has the task columns may not have run Database Verify for these.
try {
$q = $conn->prepare("SELECT COUNT(*) FROM form_submissions WHERE for_ticket_id IS NOT NULL AND $where");
$q->execute($args);
$ticketLinked = (int)$q->fetchColumn();
$ticketNeeded = 0;
if (str_starts_with($where, 'form_id')) {
$r = $conn->prepare("SELECT COUNT(*) FROM ticket_forms WHERE $where");
$r->execute($args);
$ticketNeeded = (int)$r->fetchColumn();
}
} catch (Throwable) { /* before Database Verify: nothing can be linked yet */ }
// β¦ throws ServiceError('validation', 'ticket_evidence', β¦) when either is non-zero$where is a fixed developer string ('id = ?' or "form_id IN ($place)"), never user input.
Every way a ticket closes (the reading pane, bulk actions, workflows, the REST API) goes through ChecklistsService::assertClosureAllowed() before the status is written. Ticket forms are checked first, and independently of the Checklists module switch, because forms have their own switch:
public static function assertClosureAllowed(PDO $conn, int $ticketId, ?int $tenantId = null): void
{
// GH #272: a required form on the ticket that has no submission, set to block.
// First, and independent of the Checklists switch below - forms have their own
// ("Forms on tickets"), and TicketForms::outstanding() is empty while it is off.
// Same rule as a task's forms: the form's own warn / block, with the company's
// "block all" floor (effectiveClosureMode).
$formsBlocking = [];
foreach (self::ticketFormsOutstanding($conn, $ticketId) as $f) {
if (self::effectiveClosureMode($conn, $f['mode'], $tenantId) === 'block') $formsBlocking[] = $f['title'];
}
if ($formsBlocking) {
throw new ServiceError('validation', 'forms_outstanding',
'This ticket cannot be closed until its required forms are submitted: ' . implode(', ', $formsBlocking));
}
// β¦ then the checklist rules, unchanged
}
/** Required forms on a ticket with no submission (GH #272) - empty while "Forms on tickets" is off. Never throws. */
private static function ticketFormsOutstanding(PDO $conn, int $ticketId): array
{
try {
require_once __DIR__ . '/task_forms.php';
return TicketForms::outstanding($conn, $ticketId);
} catch (Throwable) {
return [];
}
}outstanding() is inherited. Its SQL is the same for both hosts, and it returns nothing while the switch (or the Forms module) is off, so a form added before someone switched the feature off can't hold a ticket open that nobody can fill:
public static function outstanding(PDO $conn, int $hostId): array
{
if (!static::enabled($conn) || !static::columnsReady($conn)) return [];
try {
$st = $conn->prepare("SELECT title, requirement_mode FROM " . static::TABLE . "
WHERE " . static::FK . " = ? AND is_required = 1 AND submission_id IS NULL
ORDER BY sort_order, id");
$st->execute([$hostId]);
return array_map(function ($r) { return ['title' => (string)$r['title'], 'mode' => (string)$r['requirement_mode']]; },
$st->fetchAll(PDO::FETCH_ASSOC));
} catch (Throwable) {
return []; // completing / closing must not start failing on a part-upgraded install
}
}-
Block vs warn. A form set to warn doesn't refuse the close.
recordClosureOverride()(called after the close) writes the internal note naming it, alongside any skipped checklist steps. -
The company's floor. Checklists β Block every ticket with outstanding steps (
effectiveClosureMode()) upgrades warn to block for forms too. -
Workflows. The workflow action
set_ticket_statuscallsassertClosureAllowed()itself, because it writes the status directly. A blocked close fails that step and says why.
// includes/ticket_merge.php - rows moved to the surviving ticket on a merge, de-duplicated by the given column
'ticket_forms' => 'form_id', // GH #272: forms the work needs, once each (the survivor's own wins)Unmerging moves them back, as for every table in the list.
// api/tickets/permanently_delete_ticket.php
// 6a. Forms the ticket needed (GH #272) - their submissions stay: they are records of work done.
try { $conn->prepare("DELETE FROM ticket_forms WHERE ticket_id = ?")->execute([$ticketId]); } catch (PDOException $e) { if ((string)$e->getCode() !== '42S02') throw $e; }empty_trash.php adds DELETE FROM ticket_forms WHERE ticket_id IN ($place), and debug tool D002 has the same row. The 42S02 tolerance (table not found) keeps deletes working before Database Verify.
// includes/analyst_lifecycle.php - analystReferenceRegistry()
['ticket_forms', 'added_by_id', 'history'],history means a leaver's id is kept as a record of who added the form, not reassigned. tests/analyst-lifecycle.php fails for any *_by_id column that isn't registered.
Next: page 2 - the endpoints, the screens, the REST API and the tests.
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
- π PDF exports moved to PDFKit (#254)
- 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
-
π¦ Microsoft Entra ID
- β³ π₯ Importing people and groups
- β³ π» Intune: assets to their primary user
- β³ π’ Organisation chart
- β³ π οΈ Entra directory sync β Developer Guide
- β³ π οΈ Entra import - review (#235)
- β³ π οΈ Entra follow-ups - review (#237-#240)
- β³ π οΈ Intune primary user - review (#236)
- β³ π οΈ Organisation chart - reviews (#242, #265)
- π 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)
- β³ π οΈ Empty Procedures fold - review (#247)
- β³ π« 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
- β³ π Live updates
- β³ π οΈ Watching a ticket - Developer Guide (1 of 2)
- β³ π οΈ CSAT delay and reminder - Developer Guide
- β³ π οΈ Ticket checklist step types - Developer Guide (1 of 2)
- β³ π οΈ Right-click Attach checklist - Developer Guide
- β³ π οΈ Checklists in the REST API - Developer Guide
- β³ π οΈ Inbox.js map β Developer Guide
- β³ π οΈ Floating windows & the file viewer β Developer Guide
- β³ π οΈ Tickets settings β Developer Guide
- β³ π Simpler ticket and task forms - design (#218)
- β³ ποΈ The ticket workspace
- β³ π Forms on tickets
- β³ π The ticket table
- β³ π Rota copy and paste β Developer Deep Dive
- β³ β Checklists & SOPs
- β³ βοΈ Mandatory fields
- β³ π§Ύ Custom ticket fields
- β³ π·οΈ Ticket categories
- β³ π§Ύ A type for every new ticket
- β³ π₯ Assigning tickets to a team, and escalation
- β³ π’ One board across every company
- β³ π Ticket dashboard β Developer Guide
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ π Attachment size limits and large emails
-
β³ Basic IMAP mailboxes
- β³ π Basic IMAP without the PHP extension (#257)
- β³ π οΈ Basic IMAP client β Developer Guide
- β³ π§ͺ Basic IMAP client β Testing and verifying
- β³ π οΈ Basic IMAP - Sent copy - Developer Guide (#270)
- β³ π οΈ Verifying a mail folder β Developer Guide
- β³ π οΈ Mailbox providers β Developer Guide
- β³ π οΈ How Gmail mail collection works β Developer Guide
- β³ 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
- β³ π οΈ Task board and subtasks β Developer Guide
- β³ π§βπ€βπ§ People on a task ("Involved")
- β³ π Repeating tasks
- β³ π’ Moving a task between companies
- π 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
- β³ π οΈ QR asset labels β Developer Guide
- β³ π Asset label designer - design (#229)
- β³ π· Asset scanning β Developer Guide
- β³ π οΈ Asset tag numbering β Developer Guide
- β³ π οΈ Asset reconciliation β Developer Guide
- β³ π οΈ Reconciliation, tags + labels - review (#164)
- β³ π 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
- Starters & leavers
- Domains
- ποΈ Files
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
-
CMDB
- β³ π οΈ CMDB β Developer Guide
- β³ π οΈ Impact analysis β Developer Guide
- β³ π οΈ Data quality β Developer Guide
- β³ π Linked assets
- β³ π Linking an asset to its CI - design (#201)
- β³ π Discovery and redundancy-aware impact - design (#199)
- β³ π οΈ Relationship wording - review (#297)
- Network Mapper
-
Workflows
- β³ π οΈ Workflow engine β Developer Guide
- β³ π οΈ Workflows for tasks β Developer Guide
- β³ π οΈ Workflow notes on a ticket β Developer Guide
- β³ π οΈ Workflow category routing β Developer Guide
- β³ β° Time-Based Triggers
- β³ πͺ Webhooks
- β³ π¬ Webhook Message Formats
- β³ π HTTPS Certificates & CA Bundles
- β³ π οΈ SSL Verification β the Global Setting
- β³ π Workflow & Webhook Pitfalls
- Issue trackers (Jira, Azure DevOps)
- π‘ Zabbix (monitoring)
- π¬ Microsoft Teams alerts
- π 3CX (phone system)
-
System
- β³ π οΈ Preferences β Developer Guide
- β³ π§Ή Maintenance
- β³ π Finding people in the Analysts list
- β³ πͺ When an analyst leaves
- β³ π οΈ System β Branding β Developer Guide
- β³ π©Ί Page health (D019) β Developer Guide
- β³ β Feature Bingo
- β³ π·οΈ Cost centres
- β³ π· Photo Album
- β³ Demo Data
- β³ π οΈ Demo Data β Developer Guide
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ One company supporting another
- β³ π’ 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
- β³ π #301 CSAT delay was never applied
- β³ π #281 Comments imported from Jira never arrived
- β³ π #288 A change failed to open with Enforce approvals on
- β³ π Long non-Latin articles never got an embedding
- β³ π« #280 Email lookups scanned the whole table
- β³ β #266 Dragging a task told nobody
- β³ π #255 Article previews showed HTML codes
- β³ π #252 A link to a Knowledge article did not open
- β³ π #254 PDFs could not print Polish
- β³ π #250 Clicking a folder left the open article on screen
- β³ π« #260 The dashboard cog did nothing
- β³ π« #248 A department rule never matched on four routes
- β³ π A directory account could be matched to someone else
- β³ π« #244 Reply went to the assigned analyst
- β³ π A lookup answer could name any record
- β³ π« #227 A mailbox could not be deleted
- β³ π« #226 A picture on a note could not be closed
- β³ π» #222 The Assets page never loaded in 4.3.0
- β³ βοΈ #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