-
-
Notifications
You must be signed in to change notification settings - Fork 29
Checklists Module House Style
What changed between PR #141 as contributed and what shipped in 2.0.0, and why. Written mostly for Santhosh Srinivasan (Sandy), who built the module, and for anyone else contributing a module to FreeITSM later.
For how the module works, see Checklists & SOPs β developer guide.
This is FreeITSM's first community-contributed module β not a patch, a whole feature, designed and built by somebody who describes himself as a finance person who oversees an IT department and codes at weekends.
A few things in it are better than the equivalent code already in the product:
-
The template/instance split.
ticket_checklist_itemscopies the step text rather than pointing at the template, so editing a template next year cannot rewrite the history of tickets that used it. That is a decision about time and evidence, and plenty of professional developers get it wrong. - Every query uses a prepared statement. 2,834 lines, no string interpolation into SQL anywhere.
-
The module found
requireModuleAccessJson()and wired into the real access-control system rather than inventing a check. - The problem was argued before it was built. #138 reasons about why Knowledge and Tasks do not fit first. Most feature requests do not do that.
Everything below is conformance work, not corrections of judgement. The list is long because FreeITSM has a lot of conventions, and none of them are discoverable from outside.
As contributed:
$stmt = $conn->prepare("SELECT id, template_id, title,
COALESCE(created_datetime, created_at) AS created_datetime,
COALESCE(completed_by_name, completed_by) AS completed_by_name,
COALESCE(completed_datetime, completed_at) AS completed_datetime
FROM ticket_checklists WHERE ticket_id = ?");Shipped:
$stmt = $conn->prepare("SELECT id, template_id, title, created_datetime
FROM ticket_checklists WHERE ticket_id = ? ORDER BY id ASC");created_at, completed_at and completed_by are created by nothing in the PR β not the module bootstrap, not database/freeitsm.sql, not includes/db_verify_schema.php β and nothing ever writes them. MySQL treats an unknown column in a SELECT list as a hard error, so on any install but the author's, every ticket asking for its checklist got:
{"success":false,"error":"Unknown column 'created_at' in 'field list'"}The author's database still carried the older column names alongside the new ones, because it had grown through earlier versions of the module. COALESCE always found one of the two. On his machine the feature worked perfectly.
This is the oldest bug in software and it catches everybody. The lesson worth taking is not "be more careful" β it is the argument for Β§2: a schema defined in one place cannot drift away from the code that reads it.
π And
toggle_itemwrote the same phantom columns. Fixing the read path alone looked like success. It was only caught because the test drove the HTTP endpoints rather than calling the functions β see Β§9.
Every API call in the ticket-side panel was root-absolute:
fetch('/api/tickets/ticket_checklists.php?action=get_ticket_checklists&ticket_id=' + ticketId);FreeITSM can be installed in a subdirectory. There, all five calls 404 and the SOP panel silently does nothing β no error, no empty state, just a feature that is not there. On one machine, the same request:
/api/tickets/ticket_checklists.php 404
/freeitsm-app/api/tickets/ticket_checklists.php 200
Shipped:
// tickets/index.php already publishes window.API_BASE, derived from BASE_URL,
// and therefore correct whether the app is at the root or in a subdirectory.
const CHK_API = (window.API_BASE || '../api/tickets/') + 'ticket_checklists.php';π Never start a URL with
/.BASE_URL(PHP) andwindow.API_BASE(JS) exist for this. It is the same family as Β§1 β code that is correct on the machine it was written on and nowhere else β and the two together are the best argument in this whole page for testing on an install shaped differently from your own.
The six tables were created in five places: the module bootstrap (checklists/includes/db_schema.php), database/freeitsm.sql, includes/db_verify_schema.php, and β inside catch (Throwable $e) {} β by checklists/index.php and checklists/settings/index.php as a page rendered.
Two of the five disagreed:
// checklists/includes/db_schema.php, freeitsm.sql, db_verify_schema.php
created_datetime DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
// checklists/index.php and checklists/settings/index.php, inside a silent catch
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMPSo whichever page an operator happened to open first decided what the column was called. That is the machinery behind Β§1: the COALESCE fallbacks were the author defending against his own race, and they were a reasonable response to a real problem.
TIMESTAMP and DATETIME are not interchangeable either β MySQL converts TIMESTAMP on read and write, which is precisely wrong for a product that stores UTC at rest.
Shipped: database/freeitsm.sql for fresh installs, includes/db_verify_schema.php for grown ones, nothing else. The bootstrap file is deleted.
New tables go in
database/freeitsm.sqlANDincludes/db_verify_schema.php. Nowhere else. A module never creates its own tables at runtime. See Database Verification.
Running System β Database Verification reported, unprompted:
Index checklist_templates.idx_tpl_category is in freeitsm.sql but missing from the backfill list.
β¦5 indexesβ¦
π‘ Declared differently in the two files: checklist_templates.id
(freeitsm.sql has INT NOT NULL, Verification expects INT(11) NOT NULL) β¦
- Five indexes existed for fresh installs and never reached upgraded ones. Fixed by
php scripts/gen_db_verify_indexes.php. - Sixteen columns were declared
int(11)againstINTelsewhere β everyint(11)in that 235 KB file was in the checklist block. Display widths are deprecated in MySQL 8.0.17+.
If you add tables, run Database Verification afterwards and read what it says. It is checking the two schema files against each other and it is rarely wrong.
As contributed:
$stmt = $conn->prepare("DELETE FROM checklist_templates WHERE id = ?");
$stmt->execute([$id]);Shipped:
$conn->beginTransaction();
try {
$conn->prepare("DELETE FROM checklist_template_items WHERE template_id = ?")->execute([$id]);
$conn->prepare("DELETE FROM checklist_templates WHERE id = ?")->execute([$id]);
$conn->commit();
} catch (Throwable $e) { $conn->rollBack(); throw $e; }The PR's own freeitsm.sql declares ON DELETE CASCADE, so on a fresh install this worked. But Database Verification never creates foreign keys, so an upgraded install has none β and the steps were stranded permanently. Two runs of the review harness left twelve orphaned rows before anybody looked.
π Both install shapes are supported, so nothing may depend on a cascade. Delete children explicitly. (Database Integrity)
The same applied to deleting a ticket: api/tickets/permanently_delete_ticket.php already removes notes, audit rows, time entries and emails by hand, for this exact reason. The two checklist tables joined that list.
As contributed: $now = date("Y-m-d H:i:s"); β PHP's wall clock.
Shipped: completed_datetime = UTC_TIMESTAMP() in the SQL.
Measured on the reference dev box, in September:
old code stored 2026-09-14 19:42:54
correct UTC instant 2026-09-14 18:42:54
An hour in the future, because PHP runs Europe/London and MySQL runs UTC. FreeITSM stores UTC and renders in the viewer's timezone; a locally-stamped time is wrong for every reader including the one who wrote it. This had its own incident (#126, 302 rows).
api/tickets/ uses UTC_TIMESTAMP() 54 times against one NOW(). Follow the 54.
The endpoint also now reads the stored value back instead of echoing what it believed it wrote, so the response cannot disagree with the next GET.
This is the biggest structural change, and the most useful one to understand.
As contributed β assets/js/inbox.js:
if (pendingMandatory.length > 0) {
select.value = oldValue;
alert("β οΈ Cannot close/resolve ticket.\n\nβ¦");
return;
}The inbox obeyed it. The v1 REST API, bulk actions, the workflow engine and one line of curl did not. For a feature whose purpose is ISO/SOP compliance, a gate only the UI honours is worse than no gate: it reports a control that is not there.
Shipped β includes/services/checklists.php, called from TicketsService::updateTicket():
class ChecklistsService {
public static function assertClosureAllowed(PDO $conn, int $ticketId, ?int $tenantId = null): void
{ /* throws ServiceError when the operator chose 'block' */ }
public static function recordClosureOverride(PDO $conn, ActorContext $ctx, int $ticketId): array
{ /* writes an internal note naming the skipped steps */ }
}updateTicket() is the choke point every closure path goes through, so one call covers all of them.
-
method(PDO $conn, ActorContext $ctx, array $input)β no superglobals. No$_SESSION, no$_POST. -
No transport. Never
echo,header(),exit. Return data or throwServiceError. -
Typed errors.
ServiceError($kind, $code, $message); the adapter maps$kindto an HTTP status.
The endpoint becomes a thin adapter:
try {
$id = ChecklistsService::saveLookup($conn, ActorContext::fromSession($conn), $kind, $data);
echo json_encode(['success' => true, 'id' => $id]);
} catch (ServiceError $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}Full detail: Service layer β one implementation, two interfaces.
FreeITSM's position, already written in inbox.js three lines below where the checklist gate was inserted:
"closing a ticket that still has unfinished tasks WARNS, it never blocks β¦ a warning that cannot be shown must not become a block that cannot be cleared."
A hard block traps any ticket whose remaining step has become impossible. So Tickets β Settings β Checklists offers both, warn by default, and the override is recorded either way. An auditor wants the exception attributable, not impossible.
π This is a general habit here: offer the setting, not a binary. When two reasonable organisations would want different answers, that is usually a setting.
| As contributed | House | |
|---|---|---|
| Endpoints |
checklists/api.php with ?action=
|
api/<module>/<action>.php |
| JavaScript | checklists/ticket_view.js |
assets/js/ |
| CSS | checklists/ticket_checklist.css |
assets/css/ |
| Schema | five places |
freeitsm.sql + db_verify_schema.php
|
| Editor | a modal | its own screen, like forms/edit/
|
β οΈ On?action=dispatch specifically: it is a minority pattern, not a banned one. 73 of FreeITSM's 842 API files use it. One-file-per-action is the dominant convention and what new code should follow, but the contributed shape was not wrong so much as unusual.
These caused more review comments than anything architectural, and none of them are inferable from the outside.
Toggles, not tickboxes β and the class names are a trap:
<!-- π΄ .switch / .slider do not exist anywhere in FreeITSM.
Using them renders a bare checkbox, which the Time tracking tab
did for months before anybody noticed. -->
<label class="toggle-switch">
<input type="checkbox"><span class="toggle-slider"></span>
</label>Icon buttons in settings, text buttons on main screens. Settings rows use action-btn / action-btn delete with the exact SVGs from tickets/settings/index.php β copy them, do not redraw them.
π΄ And the handler must use
closest(), neverclassList:// the click lands on the inline <svg> or its <path>, NEVER on the button if (e.target.closest('.lk-del')) { β¦ }This broke the ticket-categories tab once already when its text buttons became icons.
showConfirm() and showToast(), not confirm() and alert(). Both are loaded by the waffle menu, so they are already available on any module page β no script tag needed.
Sentence case for headings and labels. One-word buttons β "Save", not "Save Template".
Full-width settings pages, and this one has a trap of its own:
/* β οΈ max-width alone is NOT enough. inbox.css sets `.container { margin: 30px auto }`,
and an auto cross-axis margin inside a flex column cancels the stretch β the page
keeps its gutters and reads exactly as though the cap were still there. */
.container { max-width: none; width: 100%; margin: 0; }i18n. Every user-visible string goes through t('namespace.key') in PHP and window.t() in JavaScript, with the English in lang/en/<module>.php. FreeITSM ships 24 locales, five of them at 100%. A hardcoded string is invisible to that machinery.
Demo data. A dataset at database/demo-data/<module>.json lets somebody evaluate the module without inventing content. Every table it writes to needs an is_demo column, because removal is DELETE FROM <table> WHERE is_demo = 1 β an earlier unqualified version of that delete emptied real tables on a live system.
π΄ And a subtlety found by Ed clicking around: editing a seeded template rewrote its steps, and a freshly inserted row defaults to
is_demo = 0while the parent stayed1. Removing the demo data then took the template and stranded its steps. Children now inherit the parent's flag. If you add demo data, try editing it and then removing it.
Offered because the method found things reading would not have.
-
Every test drove the HTTP endpoint, not the function. Fixing the phantom columns in the read path looked like success;
toggle_itemwrote the same columns and only a request revealed it. - Negative controls throughout. A test that only ever sees the good outcome cannot tell "works" from "always fires". The closure-rule test asserts that a clean close writes no override note; the drag test asserts that a drag started from a text input does not reorder.
-
The browser, for anything visual. A
.toggle-switchwhose CSS never loaded is an invisible checkbox that passes every DOM assertion, so the test measures the slider's rendered width. Clicks are dispatched at whatelementFromPointactually returns, which for an icon button is the<svg>. -
One mode per process, for settings.
tenantSetting()memoises in a static array, so flipping a setting mid-run and reading it back returns the first answer and the test passes for the wrong reason.
Three of the review's own findings were wrong before they were right β a parser that reported six missing columns that were present, an orphan query that asked "does a demo template have non-demo steps" instead of "does a step have no template at all", and a headless page-load that reported no console errors because it had served the login page. A check that can fail quietly is worse than no check.
- Checklists β closure gating: what changed and why β round two, the per-template Standard/Critical gate
- Checklists & SOPs β developer guide
- Service layer Β· Architecture Β· Database Integrity
- Database Verification Β· Internationalisation
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- π§ͺ Developer tests
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- Theming & Dark Mode
- ποΈ Recent β getting back to what you were doing
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
-
MobileβFriendly
- β³ π« Mobile: Tickets
- β³ π» Mobile: Assets
- β³ π Mobile: Calendar
- β³ π Mobile: Knowledge
- β³ π¦ Mobile: Service Status
- β³ πΌ Mobile: Watchtower
- β³ π§© Mobile: Problem Management
- β³ π Mobile: Change Management
- β³ πΏ Mobile: Software
- β³ β Mobile: Tasks
- β³ π Mobile: Forms
- β³ π Mobile: Contracts
- β³ π Mobile: Domains
- β³ π Mobile: People
- β³ π Mobile: LMS
- β³ πΊοΈ Mobile: CMDB
- β³ πΊοΈ Mobile: Network Mapper
- β³ π§ Mobile: Process Mapper
- β³ βοΈ Mobile: Workflow
- β³ π₯οΈ Mobile: System
- β³ π Mobile: Reporting
- β³ π Mobile: System Wiki
- β³ π Mobile: Self-Service Portal
- β³ π§° Mobile: Techniques & Tricks
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- β³ π‘οΈ CSRF protection (S4) β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- π CardDAV contact sync
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π REST API: Domains
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ π·οΈ REST API: Cost centres
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ π Rota copy and paste β Developer Deep Dive
- β³ β Checklists & SOPs
- β³ βοΈ Mandatory fields
- β³ π·οΈ Ticket categories
- β³ π₯ Assigning tickets to a team, and escalation
- β³ π’ One board across every company
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
-
β³
βοΈ Telegram channel - β³ β CSAT company scope and filters β Developer Guide
- β³ π₯ Microsoft Teams channel
- β³ π¨οΈ Mattermost channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ β Record previews
- β³ π Ticket notes: internal or shared
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ π¨ Telling the analyst a ticket is theirs
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ π Confidential tickets
- β³ π₯ Portal managers
- β³ π Who has seen a ticket
- β³ π Reading long tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π½ Just my tickets, or no closed ones
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- β³ π Scheduled work in your own calendar
- Problem Management
- Tasks
-
Assets
- β³ π’ Moving an asset between companies
- β³ π Shared asset locations
- β³ π§βπΌ Assigning assets to analysts
- β³ π Warranty and lease alerts
- β³ π Saved table views
- β³ π¨οΈ Recording anything, and importing it
- β³ π·οΈ QR asset labels
- β³ π Who holds what, and handover documents
- β³ π₯οΈ The inventory agent (PowerShell)
- β³ ποΈ Proxmox VE servers
- β³ βοΈ VMware Cloud Director servers
- β³ π Linking equipment to tickets
- β³ βοΈ Follow-up tasks on a ticket
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
-
Forms
- β³ π¨ The form designer β Developer Guide
- β³ π Layout & the grid β Developer Guide
- β³ ποΈ Collections β grouping submissions
- β³ π Submissions as PDFs
- β³ β‘ What happens next β a form's own actions
- β³ π οΈ Sections & conditional logic β Developer Guide
- β³ π οΈ Lookup fields β Developer Guide
- β³ π‘οΈ Catalogue request approvals
- People
- Domains
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ π’ One board across every company
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)
- What this is
-
π Bugs resolved
- β³ π’ Chat tickets ignored your ticket numbering
- β³ π Dates shown as a dash, or in server time
- β³ π Assets β Users showed people from other companies
- β³ π Restricted analysts could read other modules' data
- β³ πΌοΈ Replies with a picture in the thread failed to send
- β³ π Reply attachments never reached the customer
- β³ π οΈ Outbound email attachments β Developer Guide
- β³ π A global SSO provider was missing from the portal
- β³ π Behind a proxy, the SSO redirect said http
- β³ βοΈ The portal tagline moved when you saved it
- β³ π¨ The portal settings screen forgot what you saved
- β³ π‘οΈ The approvals inbox said "Error" and nothing else
- β³ π A table's answers were missing from the PDF
- β³ β A single-select column let you tick every option
- β³ π The portal ignored a form's field widths
- β³ π The tasks board stopped taking clicks
- β³ ποΈ #121 The index list is out of date after upgrading
- β³ π #133 The calendar subscription was empty
- β³ π #131 Tasks always reopened on the board
- β³ π₯ #129 Every page returned HTTP 500 after upgrading
- β³ π³ #127 A PHP warning above the System page
- β³ π #126 Notes stamped with the server's clock
- β³ π Storing every date in UTC
- β³ πͺ The portal was down for everyone signed in
- β³ βοΈ #120 Workflow notes could never be written
- β³ βοΈ #123 Three errors when running Database Verification
- β³ π #122 The description box was a stub in the corner
- β³ π£ Demo data deleted real accounts
- β³ π #117 Sign-in redirected to the wrong address
- β³ π¨ #108 The priority dot was invisible
- β³ β±οΈ #116 Time logged from the right-click menu
- β³ π #114 API keys refused by our own guard
- β³ ποΈ #110 Assigning a task told nobody
- β³ πͺ #107 Signed out while still working
- β³ π #103 "Share with Requester" reached nobody
- β³ π #102 Search found nothing for hyphens
- β³ πͺ #101 Source code editor opened behind
- β³ βοΈ #88 Subtasks could not be ticked off
- β³ π» #84 Asset deep link selected nothing
- β³ π« #79 A new ticket arrived with no status
- β³ π§ #79 A ticket from email did not say so
- β³ π #78 Bell opened to nothing
- β³ π¬ #77 Mail only collected from Inbox
- β³ π #74 The default password could not be changed
- β³ π¦ #70 Renaming an impact level
- β³ π€ #67 App-only mailboxes could not send
- β³ π #45 Verify only ever worked for Microsoft
- β³ π #45 IMAP reported as not authenticated
- β³ βοΈ An email template stopped escaping itself
- β³ π The portal dashboard showed the wrong time
- β³ π’ The folder said 99 and the list showed 96