Skip to content

Ticket Checklist Step Types Developer Guide

Ed Mozley edited this page Nov 11, 2026 · 1 revision

πŸ› οΈ The same step types on tickets as on tasks - Developer Guide (1 of 2): the data and the engine

How a ticket checklist step got the same types a task checklist step already had: Ask for a result (Pass / Fail / Not applicable, a Fail needs a note), a description under the step, and a required value the server checks. Built for #271 (5.0.0), changelog rows #2780 and #2781. User guide: Checklists and SOPs.

  • Page 1 (this page): the files, the schema, how the ticket methods sit beside the task methods in ChecklistsService, attaching, ticking, recording a result, the step note the server writes, removing, and the closure rule.
  • Page 2: the browser endpoint, the ticket panel's JavaScript, the template editor's description box and the save that used to wipe descriptions, the workflow action, the case report, the REST API, the end-to-end flow, the traps and the tests.

The one-line version: ticket checklists used to be copied and ticked in three places (the panel's endpoint, the workflow engine's attach_checklist action, and a note posted by the browser), none of which knew about step descriptions (#190) or results (#181). Now the ticket side lives in ChecklistsService next to the task side, with the same rules word for word: attachTemplateToTicket(), toggleTicketItem(), setTicketItemResult(), removeTicketChecklist(), ticketChecklists(). Every caller goes through them, and the step's note on the ticket is written by the server, so a tick from anywhere is on the record.


1. The files

πŸ—„οΈ schema Β· βš™οΈ service Β· πŸ”Œ endpoint Β· πŸ–₯️ UI Β· πŸ”— cross-module Β· πŸ§ͺ tests

File 🎨 What happens there
database/freeitsm.sql, includes/db_verify_schema.php πŸ—„οΈ ticket_checklists.template_version; ticket_checklist_items.description, result_mode, result, exception_note
includes/services/checklists.php βš™οΈ the "Tickets (GH #271)" section: ticketCols(), ticketChecklists(), attachTemplateToTicket(), ticketIdForItem(), ticketIdForChecklist(), ticketItemRow(), toggleTicketItem(), setTicketItemResult(), removeTicketChecklist(), writeStepNote(); recordTemplateVersion() now snapshots step descriptions (#2781)
api/tickets/ticket_checklists.php πŸ”Œ a thin adapter now: get_ticket_checklists, attach_template, toggle_item, the new set_result, remove_checklist all call the service (page 2)
checklists/ticket_view.js, tickets/index.php (cache-buster) πŸ–₯️ result steps, Pass / Fail / Not applicable buttons, the Fail prompt, step descriptions; the browser no longer posts the step note (page 2)
checklists/edit/index.php πŸ–₯️ the step description box (#2781, page 2)
checklists/api.php πŸ”Œ the template save keeps step descriptions (#2781, page 2)
workflow/includes/engine.php πŸ”— action_attach_checklist() calls attachTemplateToTicket() (page 2)
includes/case_report.php, includes/case_report_page.php πŸ”— ticket steps show their result, exception note and template version (page 2)
checklists/help.php, lang/{en,de,es}/checklists.php πŸ–₯️ the Ask for a result and Step description help cards; panel strings; the editor hint no longer says "Tickets keep a tick"
lang/{af,bn,id,it,mr,nl,ru,ta}/checklists.php πŸ–₯️ the old hint removed, so those locales fall back to the English one rather than say something untrue
tests/ticket-checklists.php πŸ§ͺ 30 checks (page 2)

Built on later by #273 (REST API, api/v1/resources/checklists.php), which calls the same methods. That is page 2, section 6.


2. The schema

ticket_checklists - one row per checklist on a ticket

CREATE TABLE IF NOT EXISTS `ticket_checklists` (
    `id` INT NOT NULL AUTO_INCREMENT,
    `ticket_id` INT NOT NULL,
    `template_id` INT NULL,
    `title` VARCHAR(255) NOT NULL,
    `closure_mode` ENUM('warn','block') NOT NULL DEFAULT 'warn',
    `created_by_id` INT NULL,
    `created_datetime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    `template_version` INT NULL,                          -- GH #271: the template version it was copied from (as tasks, #181)
  `is_demo` tinyint(1) NOT NULL DEFAULT 0,
    PRIMARY KEY (`id`),
    KEY `idx_chk_ticket` (`ticket_id`),
    CONSTRAINT `fk_ticket_checklists_ticket` FOREIGN KEY (`ticket_id`) REFERENCES `tickets` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

ticket_checklist_items - one row per step

CREATE TABLE IF NOT EXISTS `ticket_checklist_items` (
    `id` INT NOT NULL AUTO_INCREMENT,
    `ticket_checklist_id` INT NOT NULL,
    `title` VARCHAR(255) NOT NULL,
    `suggested_role` VARCHAR(100) NULL,
    `is_mandatory` TINYINT(1) NOT NULL DEFAULT 1,
    `is_completed` TINYINT(1) NOT NULL DEFAULT 0,
    `completed_by_id` INT NULL,
    `completed_by_name` VARCHAR(150) NULL,
    `completed_datetime` DATETIME NULL,
    `requires_input` TINYINT(1) NOT NULL DEFAULT 0,
    `input_placeholder` VARCHAR(255) NULL,
    `response_value` TEXT NULL,
    `sort_order` INT NOT NULL DEFAULT 1,
    `description` TEXT NULL,                              -- GH #271: the template step's description, copied (as tasks, #190)
    `result_mode` VARCHAR(10) NOT NULL DEFAULT 'none',    -- GH #271: 'pass_fail' asks for Pass / Fail / Not applicable
    `result` VARCHAR(10) NULL,                            -- pass, fail or na
    `exception_note` TEXT NULL,                           -- what went wrong, required for fail
  `is_demo` tinyint(1) NOT NULL DEFAULT 0,
    PRIMARY KEY (`id`),
    KEY `idx_item_ticket_chk` (`ticket_checklist_id`),
    CONSTRAINT `fk_ticket_chk_items_chk` FOREIGN KEY (`ticket_checklist_id`) REFERENCES `ticket_checklists` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
Column Meaning
template_version checklist_templates.version_no at the moment of the copy, so the case report can say which version of the procedure the ticket followed
description the template step's description, copied. Plain text, shown under the step
result_mode none (a tick) or pass_fail (a result). Copied from checklist_template_items.result_mode
result pass, fail or na; NULL until answered. A result also sets is_completed = 1
exception_note required for fail; kept for pass / na if one was given

The four columns mirror task_checklist_items exactly. That is on purpose: the ticket methods and the task methods run the same SQL against twin tables.

The template side already had both columns. checklist_template_items.description (from #190) and result_mode (from #181) existed before #271:

CREATE TABLE IF NOT EXISTS `checklist_template_items` (
    `id` INT NOT NULL AUTO_INCREMENT,
    `template_id` INT NOT NULL,
    `title` VARCHAR(255) NOT NULL,
    `description` TEXT NULL,
    -- …
    `result_mode` VARCHAR(10) NOT NULL DEFAULT 'none',   -- #181: 'pass_fail' asks for Pass / Fail / Not applicable on tasks
    -- …

The comment still says "on tasks". It means tasks and tickets since #271.

Database Verify

includes/db_verify_schema.php lists the same five columns, so Database Verify adds them to an existing install:

    'ticket_checklists' => [
        // …
        'template_version' => 'INT NULL',   // GH #271: which template version it was copied from (as tasks, #181)
        'is_demo'          => 'TINYINT(1) NOT NULL DEFAULT 0',   // set by the demo data importer
    ],
    'ticket_checklist_items' => [
        // …
        'description'         => 'TEXT NULL',                             // GH #271: copied from the template step (as tasks, #190)
        'result_mode'         => "VARCHAR(10) NOT NULL DEFAULT 'none'",   // GH #271: 'pass_fail' asks for a result
        'result'              => 'VARCHAR(10) NULL',                      // pass, fail or na
        'exception_note'      => 'TEXT NULL',                             // required for fail

⚠️ No foreign keys on a grown install. The CONSTRAINT … ON DELETE CASCADE lines are only in a fresh install's SQL. A table grown by Database Verify has none, so the code deletes the steps itself (section 8). See Checklists and SOPs - Developer Guide.


3. One class, two hosts

The ticket methods sit in a section of their own at the bottom of includes/services/checklists.php. Its header comment is the design:

    // ======================================================================
    //  Tickets (GH #271)
    //
    //  Ticket checklists used to live in api/tickets/ticket_checklists.php and,
    //  separately, in the workflow engine's attach_checklist action - two more
    //  copies of "copy a template", neither with step descriptions (#190) or
    //  results (#181), and a tick with no server-side check of a required value.
    //  They now share this engine with tasks. What stays ticket-shaped:
    //
    //   - History is a NOTE on the ticket ("βœ… Checklist step completed: ..."),
    //     as it always was - but written HERE, not by the browser after the
    //     fact, so a tick from the REST API or anywhere else is recorded too.
    //     A person's note goes through TicketsService::createNote, exactly as
    //     the browser's did (it fires ticket.note_added); automation, with no
    //     analyst, writes a System note (analyst_id NULL, GH #281) and no event,
    //     so a workflow cannot loop on its own notes.
    //   - The closure gate is unchanged: a result step is outstanding until it
    //     has a result, and a Fail counts as answered - it is recorded and shown,
    //     never a reason the ticket cannot close (as on tasks).
    //  Callers gate on the TICKET (analystCanAccessTicket) before calling.
    // ======================================================================

What is shared and what is a twin. "One engine" means one class with one set of rules. It does not mean one method body. The ticket methods are twins of the task methods, with the same checks and the same error messages, and they share the helpers:

Task Ticket
Read taskChecklists() ticketChecklists()
Attach attachTemplateToTask() β†’ copyTemplateToTask() attachTemplateToTicket() (copies inline)
Tick / untick toggleTaskItem() toggleTicketItem()
Result setTaskItemResult() setTicketItemResult()
Remove removeTaskChecklist() removeTicketChecklist()
Step row taskItemRow() ticketItemRow()
History task_checklist_item_events via recordStepEvent() an internal note on the ticket via writeStepNote()
Locked when closed yes, assertTaskChecklistChangeAllowed() no - a ticket's steps can be changed after it closes, as before
Shared helpers hasResultColumns(), templateVersionNo(), effectiveClosureMode() the same

πŸ”΄ TRAP: a new step column must go into both copies. copyTemplateToTask() and attachTemplateToTicket() each have their own SELECT … FROM checklist_template_items and INSERT INTO …_items. A column added to one is silently missing from the other, which is exactly how ticket steps lost descriptions and results in the first place.

Before Database Verify: ticketCols()

The ticket side must keep working, as plain ticks, on an install that has the new code but has not yet run Database Verify. One probe per column, cached for the request:

    /**
     * Which optional columns ticket checklists have yet. Before Database
     * Verification adds #271's columns the ticket side keeps working as plain
     * ticks. Cached per request.
     *
     * @return array{result: bool, description: bool, version: bool}
     */
    private static function ticketCols(PDO $conn): array
    {
        static $cols = null;
        if ($cols !== null) return $cols;
        $has = function (string $sql) use ($conn): bool {
            try { $conn->query($sql); return true; } catch (Throwable) { return false; }
        };
        return $cols = [
            'result'      => $has("SELECT result_mode, result, exception_note FROM ticket_checklist_items LIMIT 0")
                             && self::hasResultColumns($conn),
            'description' => $has("SELECT description FROM ticket_checklist_items LIMIT 0"),
            'version'     => $has("SELECT template_version FROM ticket_checklists LIMIT 0"),
        ];
    }

result also needs hasResultColumns() (the template's result_mode), because attaching reads it from the template. Every query below appends the optional columns only when the probe says they are there.

TRAP: the cache is per process. A script that runs Database Verify and then calls the service in the same process sees the old answer. tests/ticket-checklists.php avoids this by running Database Verify as a child process (scripts/db_verify_cli.php --apply) before it loads anything.


4. Reading a ticket's checklists - ticketChecklists()

Returns the same shape as taskChecklists(), so the panel, the REST API and the case report can treat both alike. Each checklist carries the gate the server will actually apply:

    public static function ticketChecklists(PDO $conn, int $ticketId): array
    {
        $tenantId = function_exists('ticketTenantId') ? ticketTenantId($conn, $ticketId) : null;
        $cols = self::ticketCols($conn);
        $st = $conn->prepare("SELECT id, template_id, title, closure_mode, created_datetime"
                             . ($cols['version'] ? ', template_version' : '') . "
                                FROM ticket_checklists WHERE ticket_id = ? ORDER BY id ASC");
        $st->execute([$ticketId]);
        $lists = $st->fetchAll(PDO::FETCH_ASSOC) ?: [];
        $extra = ($cols['result'] ? ', result_mode, result, exception_note' : '') . ($cols['description'] ? ', description' : '');
        $items = $conn->prepare("SELECT id, title, suggested_role, is_mandatory, requires_input, input_placeholder,
                                        response_value, is_completed, completed_by_id, completed_by_name, completed_datetime{$extra}
                                   FROM ticket_checklist_items WHERE ticket_checklist_id = ?
                                  ORDER BY sort_order ASC, id ASC");
        foreach ($lists as &$l) {
            $l['closure_mode'] = $l['closure_mode'] ?: 'warn';
            $l['effective_closure_mode'] = self::effectiveClosureMode($conn, $l['closure_mode'], $tenantId);
            $items->execute([(int)$l['id']]);
            $l['items'] = $items->fetchAll(PDO::FETCH_ASSOC) ?: [];
            $done = count(array_filter($l['items'], fn($i) => !empty($i['is_completed'])));
            $l['total_items'] = count($l['items']);
            $l['completed_items'] = $done;
            $l['percent'] = $l['total_items'] > 0 ? (int)round($done / $l['total_items'] * 100) : 0;
        }
        unset($l);
        return $lists;
    }

With every column present, the two queries are:

SELECT id, template_id, title, closure_mode, created_datetime, template_version
  FROM ticket_checklists WHERE ticket_id = ? ORDER BY id ASC;

SELECT id, title, suggested_role, is_mandatory, requires_input, input_placeholder,
       response_value, is_completed, completed_by_id, completed_by_name, completed_datetime,
       result_mode, result, exception_note, description
  FROM ticket_checklist_items WHERE ticket_checklist_id = ?
 ORDER BY sort_order ASC, id ASC;
  • The tenant is the ticket's company, not the analyst's active one. On a consolidated board they differ, and the padlock the panel shows must be the lock the close will apply.
  • A result counts towards the percentage because a result sets is_completed = 1 (section 7).

5. Attaching - attachTemplateToTicket()

The one copy of "attach a template to a ticket". The panel's endpoint, the right-click menu, the workflow action and the tests all call it.

    /**
     * Attach a template to a ticket: copy its title, gate, steps, their
     * descriptions and whether each asks for a result. Returns the new id, or
     * null when $skipIfAttached and that template is already on the ticket
     * (the workflow action's rule).
     *
     * @param int $templateId  the checklist_templates row; its scope must be 'ticket' or 'both'
     * @param bool $skipIfAttached  true: attach nothing when the ticket already has this template
     */
    public static function attachTemplateToTicket(PDO $conn, ActorContext $ctx, int $ticketId, int $templateId, bool $skipIfAttached = false): ?int
    {
        $tpl = $conn->prepare("SELECT title, closure_mode, scope FROM checklist_templates WHERE id = ?");
        $tpl->execute([$templateId]);
        $t = $tpl->fetch(PDO::FETCH_ASSOC);
        if (!$t) {
            throw new ServiceError('not_found', 'not_found', 'Checklist not found.');
        }
        // NULL scope is an old row from before scopes - it was always offered for tickets.
        if (!in_array((string)($t['scope'] ?? 'ticket'), ['ticket', 'both', ''], true)) {
            throw new ServiceError('validation', 'invalid_field', 'That checklist is for tasks only.');
        }
        if ($skipIfAttached) {
            $ex = $conn->prepare("SELECT 1 FROM ticket_checklists WHERE ticket_id = ? AND template_id = ? LIMIT 1");
            $ex->execute([$ticketId, $templateId]);
            if ($ex->fetchColumn()) return null;
        }
        $cols = self::ticketCols($conn);
        $own = !$conn->inTransaction();
        if ($own) $conn->beginTransaction();
        try {
            // created_by_id: the analyst, or NULL for automation. The workflow action
            // wrote 1 here - naming analyst #1, on every install, for every attach.
            $conn->prepare("INSERT INTO ticket_checklists (ticket_id, template_id, title, closure_mode, created_by_id, created_datetime)
                            VALUES (?, ?, ?, ?, ?, UTC_TIMESTAMP())")
                 ->execute([$ticketId, $templateId, (string)$t['title'], ($t['closure_mode'] ?? '') === 'block' ? 'block' : 'warn',
                            $ctx->actorId > 0 ? $ctx->actorId : null]);
            $chkId = (int)$conn->lastInsertId();
            $ver = $cols['version'] ? self::templateVersionNo($conn, $templateId) : null;
            if ($ver !== null) {
                $conn->prepare("UPDATE ticket_checklists SET template_version = ? WHERE id = ?")->execute([$ver, $chkId]);
            }
            $steps = $conn->prepare("SELECT title, description, suggested_role, is_mandatory, requires_input, input_placeholder, sort_order"
                                    . ($cols['result'] ? ', result_mode' : '') . "
                                       FROM checklist_template_items WHERE template_id = ? ORDER BY sort_order ASC, id ASC");
            $steps->execute([$templateId]);
            $ins = $conn->prepare("INSERT INTO ticket_checklist_items
                                     (ticket_checklist_id, title, suggested_role, is_mandatory, requires_input, input_placeholder, sort_order"
                                   . ($cols['result'] ? ', result_mode' : '') . ($cols['description'] ? ', description' : '') . ")
                                   VALUES (?, ?, ?, ?, ?, ?, ?" . ($cols['result'] ? ', ?' : '') . ($cols['description'] ? ', ?' : '') . ")");
            foreach ($steps->fetchAll(PDO::FETCH_ASSOC) as $i => $s) {
                $vals = [$chkId, $s['title'], $s['suggested_role'] ?: null, !empty($s['is_mandatory']) ? 1 : 0,
                         !empty($s['requires_input']) ? 1 : 0, $s['input_placeholder'] ?: null, $s['sort_order'] ?? ($i + 1)];
                if ($cols['result']) $vals[] = ($s['result_mode'] ?? 'none') === 'pass_fail' ? 'pass_fail' : 'none';
                if ($cols['description']) $vals[] = ($s['description'] ?? '') !== '' ? $s['description'] : null;
                $ins->execute($vals);
            }
            if ($own) $conn->commit();
            return $chkId;
        } catch (Throwable $e) {
            if ($own && $conn->inTransaction()) $conn->rollBack();
            throw $e;
        }
    }

The copy, written out as SQL with every column present:

INSERT INTO ticket_checklists (ticket_id, template_id, title, closure_mode, created_by_id, created_datetime)
VALUES (?, ?, ?, ?, ?, UTC_TIMESTAMP());

UPDATE ticket_checklists SET template_version = ? WHERE id = ?;

SELECT title, description, suggested_role, is_mandatory, requires_input, input_placeholder, sort_order, result_mode
  FROM checklist_template_items WHERE template_id = ? ORDER BY sort_order ASC, id ASC;

INSERT INTO ticket_checklist_items
       (ticket_checklist_id, title, suggested_role, is_mandatory, requires_input, input_placeholder, sort_order,
        result_mode, description)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);

Why each part is there:

  • It copies, it never links. Rewriting a procedure must not change a ticket already half-way through it. That is the module's founding rule, see the template/instance split.
  • Scope is checked here, not only in the picker. A task-only template is refused with invalid_field. A NULL or empty scope is an old row from before scopes and is allowed.
  • $skipIfAttached is the workflow's rule (a workflow that fires twice must not attach twice), and the right-click menu's rule on a selection. The panel passes false: a person may attach the same procedure twice on purpose.
  • created_by_id is NULL for automation. The workflow's old copy wrote 1, which put analyst #1's name against every checklist a workflow attached, on every install.
  • Its own transaction only if none is open ($own), so a caller that already holds one keeps control of it. The parent and its steps go in together or not at all.
  • template_version comes from templateVersionNo(), the same helper tasks use. NULL means the template was never versioned, or Database Verify has not run.

6. Ticking - toggleTicketItem()

Tick or untick, with the same rules as toggleTaskItem():

    public static function toggleTicketItem(PDO $conn, ActorContext $ctx, int $itemId, bool $completed, ?string $value): array
    {
        $row = self::ticketItemRow($conn, $itemId);
        if (!$row) {
            throw new ServiceError('not_found', 'not_found', 'Step not found.');
        }
        if ($completed && ($row['result_mode'] ?? 'none') === 'pass_fail' && empty($row['result'])) {
            throw new ServiceError('validation', 'missing_field', 'Record a result for this step - Pass, Fail or Not applicable.');
        }
        $value = ((int)$row['requires_input'] === 1 && $value !== null) ? trim($value) : null;
        if ($completed && (int)$row['requires_input'] === 1 && ($value === null || $value === '')) {
            throw new ServiceError('validation', 'missing_field', 'This step needs a value before it can be ticked.');
        }
        if ($completed) {
            $conn->prepare("UPDATE ticket_checklist_items
                               SET is_completed = 1, response_value = ?, completed_by_id = ?,
                                   completed_by_name = ?, completed_datetime = UTC_TIMESTAMP()
                             WHERE id = ?")
                 ->execute([$value !== '' ? $value : null, $ctx->actorId > 0 ? $ctx->actorId : null,
                            $ctx->actorName !== '' ? $ctx->actorName : null, $itemId]);
        } else {
            $conn->prepare("UPDATE ticket_checklist_items
                               SET is_completed = 0, response_value = NULL, completed_by_id = NULL,
                                   completed_by_name = NULL, completed_datetime = NULL
                             WHERE id = ?")
                 ->execute([$itemId]);
            if (array_key_exists('result_mode', $row)) {
                $conn->prepare("UPDATE ticket_checklist_items SET result = NULL, exception_note = NULL WHERE id = ?")->execute([$itemId]);
            }
        }
        $label = '[' . $row['checklist_title'] . '] "' . $row['title'] . '"';
        self::writeStepNote($conn, $ctx, (int)$row['ticket_id'], $completed
            ? 'βœ… Checklist step completed: ' . $label . ($value !== null && $value !== '' ? ' β€” Response: ' . $value : '')
            : '↩️ Checklist step reopened: ' . $label . ' was marked incomplete');
        return self::ticketItemRow($conn, $itemId) ?: [];
    }

The rules, in the order they are checked:

  1. A result step cannot just be ticked. If result_mode is pass_fail and there is no result yet, the tick is refused. A result step is answered through setTicketItemResult().
  2. A value is only kept on a step that asks for one. On any other step $value becomes NULL, so a stray value never shows up as an answer to a question nobody asked.
  3. A step that asks for a value cannot be ticked without one. This was browser-only before #271. As the task comment puts it, a tick with an empty answer is a record that says something was checked when it was not.
  4. Unticking clears everything, the result and exception note included. That is how the panel's Clear button removes a result: it sends an untick.

ticketItemRow() reads the step with its checklist's title and ticket, which the note needs:

    private static function ticketItemRow(PDO $conn, int $itemId): ?array
    {
        $extra = self::ticketCols($conn)['result'] ? ', i.result_mode, i.result, i.exception_note' : '';
        $st = $conn->prepare("SELECT i.id, i.title, i.requires_input, i.response_value, i.is_completed, i.completed_by_id,
                                     i.completed_by_name, i.completed_datetime{$extra},
                                     c.id AS checklist_id, c.title AS checklist_title, c.ticket_id
                                FROM ticket_checklist_items i JOIN ticket_checklists c ON c.id = i.ticket_checklist_id
                               WHERE i.id = ?");
        $st->execute([$itemId]);
        return $st->fetch(PDO::FETCH_ASSOC) ?: null;
    }

array_key_exists('result_mode', $row) in the untick branch is the before-Database-Verify guard: without the columns the row has no such key, and the second UPDATE would fail.


7. Recording a result - setTicketItemResult()

    /**
     * Record a ticket step's result - Pass, Fail or Not applicable - exactly as
     * setTaskItemResult() does: it ticks the step, and Fail needs a note on what
     * went wrong. A failed step does not stop the ticket closing; it is on the
     * record instead.
     *
     * @param string $result  pass, fail or na
     * @param ?string $note  the exception note - required for fail, kept for the others if given
     */
    public static function setTicketItemResult(PDO $conn, ActorContext $ctx, int $itemId, string $result, ?string $note): array
    {
        $row = self::ticketItemRow($conn, $itemId);
        if (!$row) {
            throw new ServiceError('not_found', 'not_found', 'Step not found.');
        }
        if (($row['result_mode'] ?? 'none') !== 'pass_fail') {
            throw new ServiceError('validation', 'invalid_field', 'This step does not ask for a result.');
        }
        if (!in_array($result, ['pass', 'fail', 'na'], true)) {
            throw new ServiceError('validation', 'invalid_field', 'A result is pass, fail or na.');
        }
        $note = $note !== null ? trim($note) : null;
        if ($result === 'fail' && ($note === null || $note === '')) {
            throw new ServiceError('validation', 'missing_field', 'Say what failed - a failed step needs an exception note.');
        }
        $conn->prepare("UPDATE ticket_checklist_items
                           SET result = ?, exception_note = ?, is_completed = 1,
                               completed_by_id = ?, completed_by_name = ?, completed_datetime = UTC_TIMESTAMP()
                         WHERE id = ?")
             ->execute([$result, $note !== '' ? $note : null, $ctx->actorId > 0 ? $ctx->actorId : null,
                        $ctx->actorName !== '' ? $ctx->actorName : null, $itemId]);
        $label = '[' . $row['checklist_title'] . '] "' . $row['title'] . '"';
        self::writeStepNote($conn, $ctx, (int)$row['ticket_id'], match ($result) {
            'pass'  => 'βœ… Checklist step passed: ' . $label,
            'fail'  => '❌ Checklist step failed: ' . $label . ' β€” Exception: ' . $note,
            default => 'βž– Checklist step not applicable: ' . $label,
        } . ($result !== 'fail' && $note !== null && $note !== '' ? ' β€” Note: ' . $note : ''));
        return self::ticketItemRow($conn, $itemId) ?: [];
    }
Check Error kind / code Message
the step does not exist not_found / not_found Step not found.
a plain step (result_mode not pass_fail) validation / invalid_field This step does not ask for a result.
anything but pass, fail, na validation / invalid_field A result is pass, fail or na.
fail with an empty note validation / missing_field Say what failed - a failed step needs an exception note.
  • A result ticks the step (is_completed = 1). That single choice is what makes a Fail counts as answered true everywhere: the closure rule, the progress bar and the inline card all read is_completed.
  • Why Fail needs a note. From the task twin: "a failed control with no word on what went wrong is not evidence of anything".
  • A second result overwrites the first. Choosing Pass after Fail replaces the result and the note (an empty note becomes NULL). Each change writes its own note on the ticket, so the earlier answer is still on the record.
  • requires_input is not checked here, on tickets or on tasks. A step set to both Ask for a value and Ask for a result is answered with a result only, and no value is asked for.

8. Removing - removeTicketChecklist()

    /**
     * Remove one checklist from a ticket: its steps first (no foreign key to
     * rely on), then it, in one transaction.
     */
    public static function removeTicketChecklist(PDO $conn, int $checklistId): void
    {
        $own = !$conn->inTransaction();
        if ($own) $conn->beginTransaction();
        try {
            $conn->prepare("DELETE FROM ticket_checklist_items WHERE ticket_checklist_id = ?")->execute([$checklistId]);
            $conn->prepare("DELETE FROM ticket_checklists WHERE id = ?")->execute([$checklistId]);
            if ($own) $conn->commit();
        } catch (Throwable $e) {
            if ($own && $conn->inTransaction()) $conn->rollBack();
            throw $e;
        }
    }

Children first, explicitly. House rule: Database integrity, "delete children yourself". Removing writes no note on the ticket; the panel asks first and says how many completed steps go with it.


9. The step's note - writeStepNote()

Every tick, untick and result ends with one internal note on the ticket. Until #271 the browser posted it, after a successful tick, through save_note.php. A tick from anywhere else (a workflow, the REST API, a script) left no trace.

    /**
     * A step's history line, as an internal note on the ticket. Never throws:
     * the step is saved whether or not its note is.
     *
     * A person: TicketsService::createNote, as the browser's own note did -
     * so ticket.note_added still fires for it. No person (a workflow, the
     * system): a System note, analyst_id NULL (GH #281), with no event.
     *
     * @param string $text  the note, e.g. βœ… Checklist step completed: [Onboarding] "Create account"
     */
    private static function writeStepNote(PDO $conn, ActorContext $ctx, int $ticketId, string $text): void
    {
        // GH #273: a step changed through the REST API says so, as a person reading
        // the ticket would otherwise take the key's analyst to have done it by hand.
        if ($ctx->source === 'api') $text .= ' (via the API)';
        try {
            if ($ctx->actorId > 0) {
                require_once __DIR__ . '/tickets.php';
                TicketsService::createNote($conn, $ctx, $ticketId, ['text' => $text, 'is_internal' => true]);
                return;
            }
            $conn->prepare("INSERT INTO ticket_notes (ticket_id, analyst_id, note_text, is_internal, created_datetime)
                            VALUES (?, NULL, ?, 1, UTC_TIMESTAMP())")->execute([$ticketId, $text]);
        } catch (Throwable $e) {
            error_log('[checklists] could not record a step change on ticket ' . $ticketId . ': ' . $e->getMessage());
        }
    }

The five notes it can write:

Change Note
tick βœ… Checklist step completed: [Checklist] "Step" + β€” Response: … when a value was given
untick / Clear ↩️ Checklist step reopened: [Checklist] "Step" was marked incomplete
Pass βœ… Checklist step passed: [Checklist] "Step" + β€” Note: … if one was given
Fail ❌ Checklist step failed: [Checklist] "Step" β€” Exception: …
Not applicable βž– Checklist step not applicable: [Checklist] "Step" + β€” Note: … if one was given

Why two routes:

  • A person goes through TicketsService::createNote(), the route the browser's save_note.php call used. So ticket.note_added still fires, and a workflow or a watcher that relied on it still hears about the step. It also bumps tickets.updated_datetime.
  • No person (actorId 0: a workflow) writes the row directly, with analyst_id NULL. NULL has meant System since #281. No event fires, so a workflow that reacts to notes cannot trigger itself by ticking a step. This route does not touch tickets.updated_datetime.
  • It never throws. The step is already saved; losing its note is logged, not turned into a failed tick.

The SQL of the System route:

INSERT INTO ticket_notes (ticket_id, analyst_id, note_text, is_internal, created_datetime)
VALUES (?, NULL, ?, 1, UTC_TIMESTAMP());

πŸ”΄ Never name an analyst for automation. The same lesson as the closure audit's comment in this file: analyst_id has a foreign key to analysts, and naming any id either throws (that analyst does not exist) or puts a real person's name against something they did not do.


10. The closure rule - unchanged, and why that is enough

outstandingMandatorySteps() was not touched. It reads is_completed only:

            $stmt = $conn->prepare(
                "SELECT i.id AS item_id, i.title AS step, c.title AS checklist, c.closure_mode
                   FROM ticket_checklist_items i
                   JOIN ticket_checklists c ON c.id = i.ticket_checklist_id
                  WHERE c.ticket_id = ?
                    AND i.is_mandatory = 1
                    AND i.is_completed = 0
                  ORDER BY c.id ASC, i.sort_order ASC, i.id ASC"
            );

Because a result sets is_completed = 1 and nothing else can tick a result step:

  • a mandatory result step with no result is outstanding, like an unticked step;
  • Pass, Fail and Not applicable all count as answered. A Fail never blocks the close. It is on the record instead: in the notes, in the panel, and in the case report.

The browser's warning before a close (getIncompleteMandatorySteps() in ticket_view.js, read by inbox.js) uses the same test on is_completed, so the warning and the server agree. The gate itself is Checklist closure gating - Developer Guide.

Next: page 2 - the endpoint, the panel, the template editor, the workflow action, the traps and the tests.

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Responses to feedback

Blue sky thinking

Bugs resolved

Pull requests

Links

Clone this wiki locally