Repository navigation
Workflow Ticket Notes Developer Guide
How the workflow action Add a note to the ticket (add_ticket_note) writes a real note by System, internal or shared with the requester, while every action saved before the option existed keeps writing to the ticket's history. Built for #268 (5.0.0). User guide: Workflows β Actions (the add_ticket_note row). The engine as a whole: Workflow engine - Developer Guide.
The one-line version: the action gained two args. write_to is a dropdown whose empty value means History only (a Workflow Note row in ticket_audit, as before) and whose note value inserts into ticket_notes with analyst_id NULL, which means System since #281. shared sets is_internal to 0. A new action starts as a note through new_default, which the editor applies only when an action is added or its type is changed. A plain default would have been seeded into every saved action the moment its panel was opened, and flipped it on the next save. The note dispatches no ticket.note_added, so a workflow cannot trigger itself. Before Database Verify the note falls back to the history and the run says so.
ποΈ schema Β· βοΈ engine Β· π endpoint Β· π₯οΈ UI Β· π cross-module Β· π§ͺ tests
| File | π¨ | What happens there |
|---|---|---|
workflow/includes/engine.php |
βοΈ | the add_ticket_note arg spec (write_to with new_default, shared); action_add_ticket_note(); argBool() reads shared
|
assets/js/workflow-editor.js, workflow/editor.php (?v=24) |
π₯οΈ |
argDefault(s, isNew); addAction() and updateActionTypeFromDetail() pass isNew = true; the arg form passes false
|
workflow/includes/templates.php |
βοΈ | six starter templates write 'write_to' => 'note'
|
database/freeitsm.sql, includes/db_verify_schema.php
|
ποΈ |
ticket_notes.analyst_id INT NULL (#281) |
api/system/db_verify.php |
ποΈ | the #281 probe that relaxes ticket_notes.analyst_id on existing installs; the #120 probe for ticket_audit.analyst_id
|
api/tickets/get_notes.php, assets/js/inbox.js
|
π₯οΈ |
author_kind = 'system' β System in the Notes list (unchanged by #268) |
api/self-service/get_ticket_detail.php, self-service/tickets.php
|
π₯οΈ | a shared System note shows on the portal as Support |
api/v1/resources/tickets.php |
π | the REST API returns analyst: null for a System note |
includes/case_report.php, includes/case_report_page.php
|
π |
by_system β FreeITSM
|
includes/live_pulse.php |
π | sees the new note (newest ticket_notes.id) and the history row (newest ticket_audit.id), so open screens catch up |
workflow/help.php, lang/en/workflow.php, lang/de/β¦, lang/es/β¦
|
π₯οΈ | workflow.help.actions_row5_does |
tests/workflow-ticket-note.php |
π§ͺ | 11 checks |
Nothing new was added for #268. It relies on two columns that already allow NULL.
-- database/freeitsm.sql
CREATE TABLE IF NOT EXISTS `ticket_notes` (
`id` INT NOT NULL AUTO_INCREMENT,
`ticket_id` INT NOT NULL,
`analyst_id` INT NULL, -- NULL = System: no person wrote it (an imported Jira / Azure DevOps comment) (#281)
`note_text` LONGTEXT NOT NULL,
`is_internal` TINYINT(1) NULL DEFAULT 1,
`created_datetime` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
`is_demo` TINYINT(1) NOT NULL DEFAULT 0, -- set by the demo data importer (#1297)
PRIMARY KEY (`id`),
CONSTRAINT `fk_notes_tickets` FOREIGN KEY (`ticket_id`) REFERENCES `tickets` (`id`),
CONSTRAINT `fk_notes_analysts` FOREIGN KEY (`analyst_id`) REFERENCES `analysts` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;// includes/db_verify_schema.php
'ticket_notes' => [
'id' => 'INT NOT NULL AUTO_INCREMENT',
'ticket_id' => 'INT NOT NULL',
'analyst_id' => 'INT NULL', // NULL = System: no person wrote it (#281)
// β¦
],-
analyst_idNULL is the only honest value for a note no person wrote. 0 is refused byfk_notes_analysts. That refusal is exactly what broke imported tracker comments in #281. -
is_internal1 means analysts only. 0 means it also shows on the requester's portal.
-- database/freeitsm.sql
CREATE TABLE IF NOT EXISTS `ticket_audit` (
`id` INT NOT NULL AUTO_INCREMENT,
`ticket_id` INT NOT NULL,
-- NULL = nobody did this; the workflow engine did (GH #120). β¦
`analyst_id` INT NULL,
`field_name` VARCHAR(100) NOT NULL,
`old_value` VARCHAR(500) NULL,
`new_value` VARCHAR(500) NULL,
`created_datetime` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
`is_demo` TINYINT(1) NOT NULL DEFAULT 0, -- set by the demo data importer (#1297)
PRIMARY KEY (`id`),
CONSTRAINT `fk_ticket_audit_ticket` FOREIGN KEY (`ticket_id`) REFERENCES `tickets` (`id`),
CONSTRAINT `fk_ticket_audit_analyst` FOREIGN KEY (`analyst_id`) REFERENCES `analysts` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;A workflow's history row is field_name = 'Workflow Note', analyst_id NULL, old_value NULL and the note in new_value. The same shape is written by includes/services/checklists.php and includes/services/mandatory_fields.php. The history of the action itself: Issue 120 - workflow notes could never be written.
Note the sizes: new_value is VARCHAR(500), note_text is LONGTEXT. See section 10.
// workflow/includes/engine.php - availableActions()
$ticketIdArg = [
'type' => 'text',
'label' => 'Ticket ID',
'default' => '{{ticket.id}}',
'supports_vars' => true,
'required' => true,
];
// β¦
'add_ticket_note' => [
'label' => 'Add a note to the ticket',
'description' => 'Add a note to the ticket. "A note on the ticket" writes a real note in its Notes, shown as written by System - internal unless you share it with the requester. "History only" writes it to the ticket\'s history instead, as this action always did. It never emails anyone.',
'args' => [
'ticket_id' => $ticketIdArg,
'note' => ['type' => 'textarea', 'label' => 'Note', 'required' => true, 'supports_vars' => true],
// GH #268. The EMPTY value is "History only": a workflow saved before
// this option existed has no value and must keep doing exactly what it
// did. new_default (not default) applies only when the action is first
// added - `default` is also seeded into existing actions when their
// panel is opened, which would flip a saved workflow on its next save.
'write_to' => ['type' => 'select', 'label' => 'Write it as', 'new_default' => 'note', 'options' => [
['value' => '', 'label' => 'History only (as before)'],
['value' => 'note', 'label' => 'A note on the ticket'],
]],
'shared' => ['type' => 'bool', 'label' => 'Share the note with the requester (shows on their portal)', 'default' => false],
],
],| Arg | Values | Meaning |
|---|---|---|
ticket_id |
merge code or number | starts as {{ticket.id}}, or {{task.ticket_id}} on a task trigger (trigger_defaults, added by #274 to every arg whose default is {{ticket.id}}) |
note |
text with merge codes | required; trimmed after rendering |
write_to |
'' / missing, or 'note'
|
empty or missing = History only; note = a real note |
shared |
bool | only read when write_to = note; false = internal |
shared has an ordinary default of false. That is safe: false is what a missing value already means to argBool(), so seeding it into a saved action changes nothing.
// workflow/includes/engine.php
private static function action_add_ticket_note(array $args, array $payload): array
{
$ticketId = self::argInt($args, 'ticket_id', $payload);
$note = self::argString($args, 'note', $payload);
if (!$ticketId) throw new Exception('ticket_id is required');
if ($note === '') throw new Exception('note is required');
$conn = connectToDatabase();
self::assertRecordInRunCompany($conn, 'tickets', $ticketId, 'ticket');
// GH #268: a REAL note, in the ticket's Notes, written by System (analyst_id
// NULL - possible since GH #281). Internal unless shared with the requester.
//
// TRAP: no ticket.note_added is dispatched for it. A workflow that adds a note
// whenever a note is added would otherwise trigger itself until the chain
// depth stopped it - and nobody needs a bell for a workflow's own note.
if (($args['write_to'] ?? '') === 'note') {
$shared = self::argBool($args, 'shared', $payload, false);
try {
$conn->prepare(
"INSERT INTO ticket_notes (ticket_id, analyst_id, note_text, is_internal, created_datetime)
VALUES (?, NULL, ?, ?, UTC_TIMESTAMP())"
)->execute([$ticketId, $note, $shared ? 0 : 1]);
$noteId = (int)$conn->lastInsertId();
$conn->prepare("UPDATE tickets SET updated_datetime = UTC_TIMESTAMP() WHERE id = ?")->execute([$ticketId]);
return ['ticket_id' => $ticketId, 'note_id' => $noteId, 'written_as' => 'note', 'shared' => $shared, 'note_length' => strlen($note)];
} catch (PDOException $e) {
// Before Database Verify, a note cannot be written with no author.
// The note still lands - in the history, as before - and the run says so.
$fellBack = $e->getMessage();
}
}
// History only: the ticket's audit trail, as this action always wrote it.
// analyst_id NULL marks it as written by the workflow engine.
$conn->prepare(
"INSERT INTO ticket_audit (ticket_id, analyst_id, field_name, old_value, new_value, created_datetime)
VALUES (?, NULL, 'Workflow Note', NULL, ?, UTC_TIMESTAMP())"
)->execute([$ticketId, $note]);
return ['ticket_id' => $ticketId, 'written_as' => 'history', 'note_length' => strlen($note)]
+ (isset($fellBack) ? ['fell_back' => 'Run Database Verify to write real notes: ' . $fellBack] : []);
}Things to notice:
-
write_tois compared exactly to'note'. It is not rendered as a template and not read through a helper. Anything else, including a missing key, goes to the history. That is the whole compatibility guarantee on the server side. -
The company check comes first.
assertRecordInRunCompany()runs before either write, so a company's own workflow can't reach another company's ticket in either mode. -
A real note bumps
tickets.updated_datetime, asTicketsService::createNote()does. The History only path does not, as before. -
The step's output can be chained like any action's:
{{last.note_id}},{{steps.2.written_as}}.
shared is read with the engine's checkbox helper, which tolerates the shapes old or hand-edited JSON can carry:
private static function argBool(array $args, string $key, array $payload, bool $default = false): bool
{
if (!array_key_exists($key, $args)) return $default;
$raw = $args[$key];
if (is_bool($raw)) return $raw;
if ($raw === null || $raw === '') return $default;
$s = strtolower(trim((string)$raw));
return !in_array($s, ['0', 'false', 'no', 'off'], true);
}The service is what the screen uses. It inserts with $ctx->actorId, dispatches ticket.note_added and may email the requester a shared note. A workflow's note must do none of the last two (section 6), and the action promises "It never emails anyone". So the handler writes the row itself and copies only the updated_datetime bump.
Every add_ticket_note saved before 5.0.0 has args {ticket_id, note} and no write_to. It must keep writing to the history, because the people who built it chose that behaviour, and a note suddenly appearing in Notes (or, worse, on the portal) is a change they never asked for. At the same time, a newly added action should be a real note, because that is what people expect "Add a note" to do.
Three pieces make both true:
-
The empty value means History only. A missing
write_toand''read the same:($args['write_to'] ?? '') === 'note'is false. -
There is no
defaultonwrite_to. The test checks this. -
new_defaultsays what a new action starts as, and only the add and type-change paths read it.
When the editor draws an action's form, it seeds any arg that has a starting value but no saved value into n.args, so the value round-trips through save without anyone touching the field:
// assets/js/workflow-editor.js - drawing an action's arg form
const shownDefault = argDefault(norm, false);
const currentVal = (argName in n.args) ? n.args[argName] : (shownDefault !== undefined ? shownDefault : '');
// β¦
// Seed n.args with the rendered default so it round-trips
// through save without the user having to touch every field.
if (!(argName in n.args) && shownDefault !== undefined) n.args[argName] = shownDefault;With 'default' => 'note', opening an old workflow and clicking on its note action would write write_to: 'note' into it. The next save, even one made to change something unrelated, would silently move that workflow from the history to real notes. new_default is invisible to this path, because the form passes isNew = false.
// assets/js/workflow-editor.js
/**
* An arg's starting value. trigger_defaults (GH #274) win when the workflow's trigger
* starts with one of their prefixes - a ticket action on a task trigger starts as
* {{task.ticket_id}}; then new_default, for a NEWLY added action only (GH #268); then default.
*
* @param {object} s the arg's spec
* @param {boolean} isNew a new action (or one whose type just changed)
*/
function argDefault(s, isNew) {
if (!s || typeof s !== 'object') return undefined;
if (s.trigger_defaults && triggerEvent) {
for (const pre of Object.keys(s.trigger_defaults)) if (triggerEvent.startsWith(pre)) return s.trigger_defaults[pre];
}
if (isNew && s.new_default != null) return s.new_default;
return s.default != null ? s.default : undefined;
}The two callers that pass true:
function addAction() {
const firstType = Object.keys(window.WF_ACTION_DEFS)[0];
// β¦
const def = window.WF_ACTION_DEFS[firstType] || {};
const defaults = {};
Object.keys(def.args || {}).forEach(k => {
// new_default: for a NEWLY added action only (GH #268) - never seeded into
// a saved one, whose missing value keeps meaning what it always meant.
const v = argDefault(def.args[k], true);
if (v !== undefined) defaults[k] = v;
});
const lastY = lowestNodeY('action', 'condition', 'trigger');
const n = addActionNode(COL_X, lastY + ROW_GAP, firstType, defaults);
renderAll();
selectNode(n.id);
}function updateActionTypeFromDetail() {
const n = nodes.find(x => x.id === selectedNodeId);
if (!n || n.kind !== 'action') return;
n.type = document.getElementById('wfActType').value;
// β¦
const def = window.WF_ACTION_DEFS[n.type] || {};
const next = {};
Object.keys(def.args || {}).forEach(k => {
// Changing the action's type makes it a new action: new_default applies (GH #268).
const v = argDefault(def.args[k], true);
if (v !== undefined) next[k] = v;
});
n.args = next;
showDetailForAction(n);
if (n.el) n.el.innerHTML = renderNodeContent(n);
markDirty();
}addAction() adds the first action type in the catalogue (log_message), so in practice a person adds an action and then picks Add a note to the ticket from the type dropdown. That second step is updateActionTypeFromDetail(), which is why it must also pass true. Changing type throws away the old args anyway, so it really is a new action.
| Case |
write_to in n.args
|
Dropdown shows | Saved as | Runs as |
|---|---|---|---|---|
| New action, type picked |
'note' (new_default) |
A note on the ticket | note |
real note |
| Saved before 5.0.0, panel opened | absent (no default to seed) |
History only (the '' option matches '') |
no key | history |
| Saved before 5.0.0, person picks A note on the ticket | 'note' |
A note on the ticket | note |
real note |
| New action, person picks History only | '' |
History only | '' |
history |
| Starter template cloned |
'note' (set in the template) |
A note on the ticket | note |
real note |
The select control picks the option whose value equals String(currentVal). A saved action with no write_to has currentVal = '', which matches the History only option, so the screen tells the truth about what the action will do.
Any time an existing action gains an option where new actions should behave differently from saved ones, use the same three pieces: an empty value meaning the old behaviour, new_default for the new behaviour, no default. The engine guide's TRAP says the same.
A note added on screen goes through TicketsService::createNote(), which dispatches the event:
// includes/services/tickets.php - createNote()
try {
WorkflowEngine::dispatch('ticket.note_added', [
'ticket' => self::eventTicket($ticketRow),
'note_id' => $noteId,
'is_internal' => $isInternal,
]);
} catch (Exception $wfEx) {
error_log('Workflow dispatch error in TicketsService createNote: ' . $wfEx->getMessage());
}action_add_ticket_note() deliberately does not. A workflow on ticket.note_added with an Add a note to the ticket action would otherwise fire itself on its own note. The engine's guards ($activeWorkflowIds, MAX_CHAIN_DEPTH, MAX_RUNS_PER_REQUEST) would stop it and record an aborted run, but two workflows that each note on the other's note would still ping-pong down to the depth limit, writing a note at every step.
What not dispatching also means, because these all hang off that event:
| Listener | Effect for a workflow note |
|---|---|
Other workflows on ticket.note_added
|
do not run |
The notification router and ticket watchers (includes/services/ticket_watchers.php) |
nobody gets a bell |
The search indexer (searchIndexHandleEvent(), includes/search/indexer.php) |
the ticket is not reindexed for this note; its text is picked up at the ticket's next reindex |
| The "note shared" email to the requester | not sent, even when shared is ticked |
Live updates are not affected. includes/live_pulse.php watches the newest ticket_notes.id and ticket_audit.id directly, so an open ticket catches up either way.
On an install upgraded to 5.0.0 that has not yet run System β Database Verify, ticket_notes.analyst_id may still be NOT NULL. The insert then throws PDOException, and the handler:
- keeps the message in
$fellBack; - writes the note to
ticket_auditinstead, exactly as History only does; - returns
written_as = 'history'plusfell_back = 'Run Database Verify to write real notes: β¦'.
The step counts as a success, so the rest of the workflow still runs and the note is not lost. The run's step output in the execution log is where the author sees why it went to the history.
The probe that makes the column nullable came with #281:
// api/system/db_verify.php
try {
$notesCol = $conn->prepare(
"SELECT IS_NULLABLE FROM information_schema.columns
WHERE table_schema = ? AND table_name = 'ticket_notes' AND column_name = 'analyst_id'"
);
$notesCol->execute([$dbName]);
$notesRow = $notesCol->fetch(PDO::FETCH_ASSOC);
$notesDetails = [];
if ($notesRow && strtoupper($notesRow['IS_NULLABLE']) === 'NO') {
$conn->exec("ALTER TABLE `ticket_notes` MODIFY `analyst_id` INT NULL");
$notesDetails[] = "analyst_id: NOT NULL β NULL (a note no person wrote - an imported Jira comment - is System)";
}
if ($notesRow && strtoupper($notesRow['IS_NULLABLE']) === 'YES' || $notesDetails) {
$zeros = $conn->exec("UPDATE `ticket_notes` SET `analyst_id` = NULL WHERE `analyst_id` = 0");
if ($zeros) $notesDetails[] = "$zeros note(s) with no author (analyst 0) now marked System";
}
// β¦ step 3: drop integration_comment_map rows whose imported note was refused
if ($notesDetails) {
$results[] = ['table' => 'ticket_notes', 'status' => 'updated', 'details' => $notesDetails];
}
} catch (Exception $e) {
// Non-fatal: notes keep working for people; only imported comments stay refused.
}The column pass in Database Verify adds missing columns but never loosens existing ones, hence the separate probe. Relaxing NOT NULL invalidates no existing row, so it is safe to re-run. The full story: Issue 281 - imported comments never arrived.
ticket_audit.analyst_id was relaxed the same way for #120, so the fallback itself needs nothing new.
Every reader of ticket_notes already used a LEFT JOIN on analysts and already handled a missing author (#281). #268 changed no reader.
// api/tickets/get_notes.php - after the LEFT JOIN query
if ($note['analyst_name']) {
$note['author_kind'] = 'analyst';
} elseif ($tracker) {
$note['author_kind'] = 'tracker';
$note['analyst_name'] = (string)$tracker['connection_name'];
} elseif ((int)$note['analyst_id'] > 0) {
$note['author_kind'] = 'former';
} else {
$note['author_kind'] = 'system';
}// assets/js/inbox.js - renderNotes()
let author = note.analyst_name;
if (!author) {
author = note.author_kind === 'system'
? t('tickets.note_author.system')
: t('tickets.note_author.former');
}tickets.note_author.system is System in English. A workflow note has no tracker map row and a NULL analyst_id, so it lands on system. The note text is drawn through NoteImages.render(), which escapes it, so merge-code values are shown as plain text.
| Where | What a workflow note looks like |
|---|---|
The ticket's history (api/tickets/get_ticket_audit.php, auditAuthor() in inbox.js) |
History only: a Workflow Note row by System (analyst_id IS NULL β author_kind = 'system') |
Portal (get_ticket_detail.php selects is_internal = 0 only; self-service/tickets.php) |
a shared note shows by Support: n.analyst_name || window.t('self-service.ticket.support'). An internal one never reaches the portal |
REST API GET /tickets/{id}/notes
|
"analyst": null |
| Case report |
FreeITSM (case-report.by_system), not "former analyst" |
Six templates write a real note. Each sets 'write_to' => 'note' explicitly, because a template's args are copied straight into the new workflow and never go through argDefault():
| Template key | Name |
|---|---|
task_done_note_on_ticket |
When a task is done, note it on its ticket (#274) |
tracker_tell_requester_when_done |
Tell the requester when the developers finish |
p1_incident_response |
P1 incident response |
ticket_escalated_to_p1 |
Alert when a ticket is escalated to P1 |
sla_escalation |
Escalate before the SLA breaches |
sla_breach_alert |
Alert when an SLA is breached |
// workflow/includes/templates.php - sla_breach_alert
'type' => 'add_ticket_note',
'args' => [
'ticket_id' => '{{ticket.id}}',
'write_to' => 'note', // GH #268: a real note, by System
'note' => 'SLA BREACHED: the {{sla.target}} target was missed by {{sla.overdue_minutes}} minutes.',
],None sets shared, so all six write internal notes.
- An analyst adds an action in the workflow editor and picks Add a note to the ticket.
updateActionTypeFromDetail()seedsticket_id = {{ticket.id}}(or{{task.ticket_id}}on a task trigger) andwrite_to = 'note'fromnew_default. Drawing the form seedsshared = false. - Save stores
{"type":"add_ticket_note","args":{"ticket_id":"{{ticket.id}}","write_to":"note","shared":false,"note":"β¦"}}inworkflows.actions. - The trigger fires.
executeActionList()renders the args against the payload and callsaction_add_ticket_note(). - The handler checks the ticket is in the run's company, inserts into
ticket_noteswithanalyst_idNULL andis_internal1 (or 0 when shared), and bumpstickets.updated_datetime. No event is dispatched. - The step returns
note_idandwritten_as = 'note', recorded inworkflow_executions. - Open screens pick the note up on the next live-updates heartbeat. The Notes list shows it by System. If shared, the requester sees it on the portal by Support.
A workflow saved before 5.0.0 follows the same path, except that write_to is missing at step 4, so it writes the Workflow Note history row instead. A dry run writes nothing in either mode: it records would_args, which shows the write_to the real run would use.
-
TRAP: never give
write_toadefault. The editor seedsdefaultinto saved actions when their panel is drawn (section 5).tests/workflow-ticket-note.phpfails if one appears. -
TRAP: never dispatch
ticket.note_addedfrom the action (section 6). A workflow could then trigger itself, and every watcher would get a bell for automation. -
History only is capped at 500 characters.
ticket_audit.new_valueisVARCHAR(500). On a MySQL server in strict mode (the MySQL 8 default) a longer rendered note fails the step with Data too long; without strict mode it is cut. A real note (LONGTEXT) has no such limit. The pre-Database-Verify fallback goes to the history too, so it shares the cap. -
sharedis ignored for History only. The checkbox has noshow_when, so it stays visible when History only is picked, but the handler reads it only inside thenotebranch. -
Other ways to build an action do not read
new_default. The form actions editor (forms/edit/index.php) showsspec.defaultor the first option, so a new Add a note to the ticket on a form starts as History only. A REST API caller (POST /workflows) gets History only unless it sends"write_to": "note";GET /workflow-actionslists each arg'sdefaultbut neithernew_defaultnor a select'soptions. -
Always
$args['write_to'] ?? ''. Reading the key directly would raise a warning on every saved action that predates the option.
| Key | Where | Says |
|---|---|---|
workflow.help.actions_row5_does |
workflow/help.php, lang/en/workflow.php, lang/de/β¦, lang/es/β¦
|
the two modes, that a note is by System and internal unless shared, that saved actions keep History only, and that it never emails |
workflow.help.actions_row5_args |
same | still ticket_id, note
|
The other locales still carry the old "appends a free-text note to the audit trail" wording until they are next translated.
tests/workflow-ticket-note.php, 11 checks. It runs on the throwaway database config.php uses and skips itself unless FREEITSM_TEST_DATABASE names that database:
FREEITSM_TEST_DATABASE=<the throwaway database config.php uses> php tests/workflow-ticket-note.php
It needs Database Verify to have run on that database, so that ticket_notes.analyst_id allows NULL. It runs each action through WorkflowEngine::runActionList() against the newest existing ticket, tags every note ZZ268-<random>, and in finally deletes the notes, history rows and execution rows it made.
$run = function (array $args) use ($tid, &$runs): array {
$r = WorkflowEngine::runActionList('ZZ268 test', 'ticket.created',
[['type' => 'add_ticket_note', 'args' => ['ticket_id' => '{{ticket.id}}'] + $args]],
['ticket' => ['id' => $tid]]);
if (!empty($r['execution_id'])) $runs[] = (int)$r['execution_id'];
return $r;
};| Section | Proves |
|---|---|
| A workflow saved before the option existed | args without write_to write one Workflow Note history row with analyst_id NULL, and no note |
| History only |
write_to = '' writes the history, not a note |
| A note on the ticket | a real note with merge codes filled in; analyst_id NULL; is_internal 1; no history row as well; shared = true gives is_internal 0 |
| The designer |
write_to has new_default = 'note', has no default, and its first option's value is ''
|
What it does not cover: the pre-Database-Verify fallback (the test database has already been verified), and the editor itself. The editor was checked in a real browser when #268 was built: a new action opened as A note on the ticket, a saved one as History only.
Also run after a change here: workflow-task-actions (its end-to-end example writes a real note), tracker-comment-import (#281), workflow-company-scope, case-report.
- Workflows (user guide) β Actions Β· Workflow engine - Developer Guide
-
Workflows for tasks - Developer Guide (
trigger_defaults, the task-done recipe) -
Issue 281 - imported comments never arrived (why
analyst_idNULL is System) - Issue 120 - workflow notes could never be written (the History only row)
- GitHub #268
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